Spaces:
Running
Running
| """ | |
| 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" | |
| 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 | |