""" NeuroJenML — Automated paper pre-screening (free Gemma). Before a paper reaches the human review queue, an automated screen gates it on three checks (see DATA_HANDLING.md §3.1): 1. has_content — enough extractable text to be a real paper, not a stub/scan. 2. is_ad_related — about Alzheimer's disease / neurodegeneration. 3. is_scientific — a scientific study (methods/results), not an op-ed/news/ad. A paper passes only if ALL THREE are true. Passing papers move to `pending` (human queue); failing papers move to `screened_out` with a reason, so a human never has to triage obvious junk. Backend: free Gemma via the HF router (no MedGemma needed for this cheap gate). Degrades gracefully — if HF_TOKEN is missing or the call fails, it falls back to a conservative heuristic screen so the pipeline never stalls. """ import os import json import httpx # Minimum characters of real text to be considered a paper (not a scanned image # or a near-empty stub). MIN_CONTENT_CHARS = 600 # Screening uses TWO models in parallel to reduce hallucination risk on the # three-check gate. Both must agree for the paper to pass; on disagreement the # result is conservative (fail unless the difference is immaterial — see # _merge_verdicts below). Running two small models is cheaper and more reliable # than trusting one alone. # # Primary: google/gemma-2-2b-it — confirmed free-tier, user-verified no errors. # Secondary: google/gemma-3-4b-it — stronger, used as cross-check. # # Override the models: # SCREENING_MODEL_PRIMARY (default: google/gemma-2-2b-it) # SCREENING_MODEL_SECONDARY (default: google/gemma-3-4b-it) # Set both to the same model to disable dual-model mode. _DEFAULT_PRIMARY_MODEL = "google/gemma-2-2b-it" _DEFAULT_SECONDARY_MODEL = "google/gemma-3-4b-it" # Legacy single-model env var still honoured — sets the primary if present. _DEFAULT_SCREENING_MODEL = _DEFAULT_PRIMARY_MODEL # AD / neurodegeneration signal terms for the heuristic fallback. _AD_TERMS = [ "alzheimer", "dementia", "amyloid", "tau", "neurodegener", "cognitive decline", "neurofibrillary", "apoe", "mci", "mild cognitive impairment", "beta-amyloid", "abeta", "a\u03b2", "neuroinflammation", "tauopathy", "hippocamp", ] # Markers that a document is a scientific study rather than prose/news. _SCIENCE_TERMS = [ "method", "results", "conclusion", "abstract", "hypothesis", "p <", "p<", "p =", "cohort", "sample", "statistical", "experiment", "assay", "control group", "figure", "table", "doi", "et al", "regression", "significant", ] _SCREEN_PROMPT = ( "You are a screening gate for an Alzheimer's disease (AD) research dataset. " "Read the document excerpt and decide three booleans, then return STRICT JSON only:\n" ' "has_content": true if it contains substantive readable text (not an empty/' 'corrupted/scanned-image stub).\n' ' "is_ad_related": true if it concerns Alzheimer\'s disease, dementia, or ' "neurodegeneration (central or via a systemic/peripheral link).\n" ' "is_scientific": true if it is a scientific study or review (has methods, ' "data, or structured findings) rather than news, opinion, marketing, or fiction.\n" ' "reason": one short sentence explaining the decision.\n' "Return ONLY the JSON object." ) def _heuristic_screen(text: str) -> dict: """Conservative keyword-based screen used when the LLM is unavailable.""" lower = (text or "").lower() has_content = len((text or "").strip()) >= MIN_CONTENT_CHARS and not lower.startswith("[pdf binary") is_ad_related = any(term in lower for term in _AD_TERMS) science_hits = sum(1 for term in _SCIENCE_TERMS if term in lower) is_scientific = science_hits >= 3 passed = has_content and is_ad_related and is_scientific return { "passed": passed, "checks": { "has_content": has_content, "is_ad_related": is_ad_related, "is_scientific": is_scientific, }, "reason": _reason(has_content, is_ad_related, is_scientific), "model": "heuristic", } def _reason(has_content: bool, ad: bool, sci: bool) -> str: if not has_content: return "Insufficient extractable text — likely an empty, stub, or scanned document." if not ad: return "Does not appear to be related to Alzheimer's disease or neurodegeneration." if not sci: return "Does not appear to be a scientific study (missing methods/data/structure)." return "Passed: substantive, AD-related, scientific content." def _coerce_verdict(obj: dict, model: str) -> dict: checks = { "has_content": bool(obj.get("has_content")), "is_ad_related": bool(obj.get("is_ad_related")), "is_scientific": bool(obj.get("is_scientific")), } passed = all(checks.values()) reason = obj.get("reason") or _reason(**{ "has_content": checks["has_content"], "ad": checks["is_ad_related"], "sci": checks["is_scientific"], }) return {"passed": passed, "checks": checks, "reason": str(reason)[:300], "model": model} def _parse_json_lenient(raw: str) -> dict: if not raw: return {} try: return json.loads(raw) except Exception: pass start = raw.find("{") if start == -1: return {} depth = 0 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 {} async def _call_model(token: str, model: str, prompt: str) -> dict: """Call one HF model and return a coerced verdict, or None on failure.""" try: from huggingface_hub import AsyncInferenceClient client = AsyncInferenceClient(api_key=token) resp = await client.chat.completions.create( model=model, messages=[{"role": "user", "content": prompt}], temperature=0.0, max_tokens=300, ) raw = resp.choices[0].message.content parsed = _parse_json_lenient(raw) return _coerce_verdict(parsed, model) if parsed else None except Exception: return None def _merge_verdicts(a: dict, b: dict) -> dict: """Consensus logic: conservative — FAIL if any model raises a check failure. Rationale: false negatives (passing bad papers) are worse than false positives (rejecting good ones). A human can always override a screened-out paper; a hallucinated pass can corrupt the dataset. Both models must agree on PASS for the paper to pass. If only one model returned a result, its verdict is used directly. """ if a is None and b is None: return None # both failed → fall through to heuristic if a is None: return b if b is None: return a # Each check: True only if both models say True. merged_checks = { k: a["checks"].get(k, False) and b["checks"].get(k, False) for k in ("has_content", "is_ad_related", "is_scientific") } passed = all(merged_checks.values()) # Prefer the more informative reason (the one that failed, if any). reason = ( b["reason"] if not merged_checks.get("is_ad_related") or not merged_checks.get("is_scientific") else a["reason"] ) return { "passed": passed, "checks": merged_checks, "reason": str(reason)[:300], "model": f"{a['model']}+{b['model']}", } async def screen(text: str) -> dict: """Screen a paper. Returns {passed, checks, reason, model}. Runs TWO models in parallel and merges via conservative consensus to reduce hallucination risk. Cheap length guard runs first; heuristic fallback fires if both model calls fail so the pipeline never stalls. """ import asyncio as _asyncio text = text or "" # Hard fail on no content without spending any model call. if len(text.strip()) < MIN_CONTENT_CHARS or text.strip().lower().startswith("[pdf binary"): return { "passed": False, "checks": {"has_content": False, "is_ad_related": False, "is_scientific": False}, "reason": "Insufficient extractable text — likely an empty, stub, or scanned document.", "model": "guard", } token = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_TOKEN") if not token: return _heuristic_screen(text) # Resolve model IDs. Legacy SCREENING_MODEL_ID overrides the primary. primary = os.getenv("SCREENING_MODEL_ID") or os.getenv("SCREENING_MODEL_PRIMARY", _DEFAULT_PRIMARY_MODEL) secondary = os.getenv("SCREENING_MODEL_SECONDARY", _DEFAULT_SECONDARY_MODEL) prompt = f"{_SCREEN_PROMPT}\n\n--- DOCUMENT EXCERPT ---\n{text[:6000]}\n--- END ---\n\nJSON:" async def _noop(): return None # Fire both models concurrently. a, b = await _asyncio.gather( _call_model(token, primary, prompt), _call_model(token, secondary, prompt) if secondary != primary else _noop(), return_exceptions=False, ) merged = _merge_verdicts(a, b) if merged is None: return _heuristic_screen(text) return merged