neurojenml-api / extractor.py
sciedoc's picture
Sync from GitHub
27e7c15 verified
Raw
History Blame Contribute Delete
13.2 kB
"""
NeuroJenML β€” Structured paper extraction.
Turns approved paper text into a standardized, provenance-carrying record.
Content schema (see DATA_HANDLING.md Β§2.2):
hypothesis, logic, results, explanations, methods, maps
Every field carries provenance (source section + confidence).
Swappable backends
──────────────────
MedGemmaExtractor (default)
Uses google/gemma-3-12b-it via the HF Inference Router (free, no dedicated
endpoint needed). This is the strongest freely-available instruct model on
the HF serverless tier and is confirmed accessible without a paid plan.
Requires: HF_TOKEN (a standard HF read token β€” no special model access needed)
Override: EXTRACTION_MODEL_ID (any HF chat/instruct model on the router)
FrontierExtractor (optional, high-quality offline curation pass)
Provider-agnostic: detects Anthropic vs. generic OpenAI-compatible from the
base URL and uses the correct wire format for each.
Requires: FRONTIER_API_KEY
Optional: FRONTIER_API_BASE (default: https://api.openai.com)
FRONTIER_MODEL_ID (default: gpt-4o)
Examples:
OpenAI: FRONTIER_API_BASE=https://api.openai.com FRONTIER_MODEL_ID=gpt-4o
Anthropic: FRONTIER_API_BASE=https://api.anthropic.com FRONTIER_MODEL_ID=claude-opus-4-5
Together: FRONTIER_API_BASE=https://api.together.xyz FRONTIER_MODEL_ID=meta-llama/Llama-3-70b-chat-hf
Groq: FRONTIER_API_BASE=https://api.groq.com/openai FRONTIER_MODEL_ID=llama3-70b-8192
Both degrade gracefully: if credentials are absent or the call fails, extract()
returns a record with an `error` field instead of raising, so /api/ingest never
fails or blocks.
"""
import os
import json
from abc import ABC, abstractmethod
import httpx
import taxonomy
# ── Content schema ────────────────────────────────────────────────────────────
CONTENT_FIELDS = ("hypothesis", "logic", "results", "explanations", "methods", "maps")
# Default extraction model: Gemma 3 12B instruct is freely available on the HF
# serverless router and outperforms Gemma 2 on structured instruction-following.
_DEFAULT_EXTRACTION_MODEL = "google/gemma-3-12b-it"
_SYSTEM_PROMPT = (
"You are a biomedical research extraction engine for Alzheimer's disease (AD) "
"and neurodegeneration. Read the paper text and return STRICT JSON with these keys:\n"
' "hypothesis": string β€” the central hypothesis or aim.\n'
' "logic": string β€” the reasoning/rationale linking premise to expected result.\n'
' "results": string β€” key quantitative/qualitative findings (include effect sizes if present).\n'
' "explanations": string β€” the authors\' mechanistic interpretation.\n'
' "methods": string β€” model system, species, assays, sample size.\n'
' "maps": array of {"from": string, "relation": string, "to": string, "confidence": 0-1}\n'
"\n"
"CRITICAL RULES for the 'maps' field:\n"
"- Use SPECIFIC biological pathway names, NOT vague verbs.\n"
" BAD: {\"from\": \"IL-6\", \"relation\": \"influences\", \"to\": \"brain\"}\n"
" GOOD: {\"from\": \"IL-6\", \"relation\": \"activates JAK-STAT3 signaling in astrocytes\", \"to\": \"neuroinflammation\"}\n"
"- Each map must name the actual mechanism (e.g. \"disrupts tight junctions\", \"activates microglial TLR4\", "
"\"impairs insulin signaling\", \"increases BBB permeability via MMP-9\").\n"
"- If the paper doesn't specify a mechanism, use \"correlates with\" but add context: "
"\"correlates with (mechanism unspecified in paper)\".\n"
"- Include quantitative strength when stated (e.g. \"r=0.85\", \"p<0.01\", \"2.3-fold increase\").\n"
"\n"
"Be faithful to the text. If a field is absent, use an empty string or empty array. "
"Return ONLY the JSON object, no prose, no markdown."
)
# ── Helpers ───────────────────────────────────────────────────────────────────
def _build_prompt(text: str) -> str:
# Keep within context limits; 12 000 chars β‰ˆ 3 000 tokens.
snippet = text[:12000]
return (
f"{_SYSTEM_PROMPT}\n\n"
"--- PAPER TEXT ---\n"
f"{snippet}\n"
"--- END ---\n\n"
"JSON:"
)
def _empty_fields() -> dict:
return {f: ([] if f == "maps" else "") for f in CONTENT_FIELDS}
def _coerce_fields(obj: dict) -> dict:
"""Normalize a parsed model response into the canonical field shape."""
fields = _empty_fields()
if not isinstance(obj, dict):
return fields
for f in CONTENT_FIELDS:
if f == "maps":
maps = obj.get("maps") or []
clean = []
if isinstance(maps, list):
for m in maps:
if isinstance(m, dict) and m.get("from") and m.get("to"):
clean.append({
"from": str(m.get("from"))[:200],
"relation": str(m.get("relation", "relates_to"))[:80],
"to": str(m.get("to"))[:200],
"confidence": m.get("confidence"),
})
fields["maps"] = clean
else:
val = obj.get(f, "")
fields[f] = val if isinstance(val, str) else json.dumps(val)
return fields
def _parse_json_lenient(raw: str) -> dict:
"""Extract the first JSON object from a model response."""
if not raw:
return {}
try:
return json.loads(raw)
except Exception:
pass
start, depth = raw.find("{"), 0
if start == -1:
return {}
for i in range(start, len(raw)):
if raw[i] == "{":
depth += 1
elif raw[i] == "}":
depth -= 1
if depth == 0:
try:
return json.loads(raw[start:i + 1])
except Exception:
return {}
return {}
# ── Base class ────────────────────────────────────────────────────────────────
class Extractor(ABC):
name = "base"
@abstractmethod
async def _generate(self, prompt: str) -> str:
"""Return raw model text for the prompt, or raise on failure."""
async def extract(self, text: str) -> dict:
"""Extract structured findings + classification + provenance."""
classification = taxonomy.heuristic_classify(text)
record = {
"model": self.name,
"fields": _empty_fields(),
"classification": classification,
"provenance": {"source": "model", "extractor": self.name},
"error": None,
}
if not text.strip():
record["error"] = "empty text"
return record
try:
raw = await self._generate(_build_prompt(text))
record["fields"] = _coerce_fields(_parse_json_lenient(raw))
except Exception as e:
record["error"] = str(e)[:300]
return record
# ── HF Router extractor (default, free) ──────────────────────────────────────
class HFExtractor(Extractor):
"""
Calls any HF Inference Router chat/instruct model via the OpenAI-compatible
/v1/chat/completions endpoint.
Default model: google/gemma-3-12b-it
- Freely available on the HF serverless tier (no gated access, no paid plan).
- Strongly outperforms smaller models on structured JSON generation.
- Confirmed on the router as of mid-2025.
Override EXTRACTION_MODEL_ID with any publicly-accessible HF chat model,
e.g. "mistralai/Mistral-7B-Instruct-v0.3", "HuggingFaceH4/zephyr-7b-beta".
"""
name = "hf-gemma"
def __init__(self):
self.token = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_TOKEN")
self.model = os.getenv("EXTRACTION_MODEL_ID", _DEFAULT_EXTRACTION_MODEL)
self.name = f"hf:{self.model.split('/')[-1]}"
async def _generate(self, prompt: str) -> str:
if not self.token:
raise RuntimeError(
"HF_TOKEN not set β€” cannot run extraction. "
"Add HF_TOKEN to your Vercel environment variables."
)
from huggingface_hub import AsyncInferenceClient
client = AsyncInferenceClient(api_key=self.token)
resp = await client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=0.1,
max_tokens=1500,
)
return resp.choices[0].message.content
# ── Frontier extractor (optional, high-quality) ───────────────────────────────
class FrontierExtractor(Extractor):
"""
Provider-agnostic high-quality extractor for offline curation passes.
Detects Anthropic vs. generic OpenAI-compatible from FRONTIER_API_BASE.
Required env vars:
FRONTIER_API_KEY β€” your API key for the provider
Optional env vars:
FRONTIER_API_BASE β€” provider base URL (default: https://api.openai.com)
FRONTIER_MODEL_ID β€” model to use (default: gpt-4o)
Provider examples:
OpenAI: FRONTIER_API_BASE=https://api.openai.com FRONTIER_MODEL_ID=gpt-4o
Anthropic: FRONTIER_API_BASE=https://api.anthropic.com FRONTIER_MODEL_ID=claude-opus-4-5
Together: FRONTIER_API_BASE=https://api.together.xyz FRONTIER_MODEL_ID=meta-llama/Llama-3-70b-chat-hf
Groq: FRONTIER_API_BASE=https://api.groq.com/openai FRONTIER_MODEL_ID=llama3-70b-8192
Mistral: FRONTIER_API_BASE=https://api.mistral.ai FRONTIER_MODEL_ID=mistral-large-latest
"""
name = "frontier"
def __init__(self):
self.key = os.getenv("FRONTIER_API_KEY")
self.base = (os.getenv("FRONTIER_API_BASE") or "https://api.openai.com").rstrip("/")
self.model = os.getenv("FRONTIER_MODEL_ID", "gpt-4o")
self.name = f"frontier:{self.model.split('/')[-1]}"
def _is_anthropic(self) -> bool:
return "anthropic.com" in self.base
async def _generate(self, prompt: str) -> str:
if not self.key:
raise RuntimeError(
"FRONTIER_API_KEY not set β€” frontier extraction unavailable. "
"This is optional; extraction will use the HF model instead."
)
if self._is_anthropic():
return await self._call_anthropic(prompt)
return await self._call_openai_compatible(prompt)
async def _call_openai_compatible(self, prompt: str) -> str:
"""Works for OpenAI, Together, Groq, Mistral, and any OpenAI-compatible API."""
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.post(
f"{self.base}/v1/chat/completions",
headers={
"Authorization": f"Bearer {self.key}",
"Content-Type": "application/json",
},
json={
"model": self.model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.1,
"max_tokens": 2000,
},
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
async def _call_anthropic(self, prompt: str) -> str:
"""Anthropic Messages API (different wire format)."""
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.post(
f"{self.base}/v1/messages",
headers={
"x-api-key": self.key,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
},
json={
"model": self.model,
"max_tokens": 2000,
"messages": [{"role": "user", "content": prompt}],
},
)
resp.raise_for_status()
data = resp.json()
return "".join(block.get("text", "") for block in data.get("content", []))
# ── Factory ───────────────────────────────────────────────────────────────────
def get_extractor(mode: str = "default") -> Extractor:
"""
Factory function.
'default' | 'hf' | 'gemma' -> HFExtractor (google/gemma-3-12b-it by default)
'frontier' -> FrontierExtractor
"""
if mode == "frontier":
return FrontierExtractor()
return HFExtractor()
# Keep the old name as an alias so any code referencing MedGemmaExtractor still works.
MedGemmaExtractor = HFExtractor