Research-Stack/5-Applications/tools-scripts/literature/literature_probe.py

1316 lines
48 KiB
Python

# ==============================================================================
# COPYRIGHT NO ONE EVERYWHERE LLC (WYOMING HOLDING COMPANY)
# PROJECT: SOVEREIGN STACK
# This artifact is entirely proprietary and cryptographically proven.
# Open-Source usage requires explicit permission from Brandon Scott Schneider.
# ==============================================================================
from __future__ import annotations
# [WARDEN BOUNDARY ENFORCEMENT INJECTED]
import sys
import os
try:
from io_harness_compat import spawn_isolated_process, fetch_network_resource
except ImportError:
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from io_harness_compat import spawn_isolated_process, fetch_network_resource
#!/usr/bin/env python3
"""Question-focused literature probe using official scholarly APIs.
This script is intentionally tuned for "boundary mapping" rather than broad
harvesting. It takes one research question, queries several official scholarly
APIs, deduplicates results, scores them against the actual question, extracts
recurring nearby vocabulary, and labels the likely kind of wall you are
running into.
Default sources:
- OpenAlex
- Crossref
- Semantic Scholar Graph API
- arXiv API
No third-party dependencies are required.
"""
import argparse
import json
import math
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from collections import Counter
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
PROJECT_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_OUT_DIR = PROJECT_ROOT / "out" / "literature_probe"
BASE_USER_AGENT = "research-stack-literature-probe/1.0"
STOPWORDS = {
"a", "an", "and", "are", "as", "at", "be", "because", "by", "can", "could",
"do", "does", "for", "from", "how", "if", "in", "into", "is", "it", "its",
"more", "not", "of", "on", "or", "our", "same", "should", "so", "than",
"that", "the", "their", "them", "there", "these", "they", "this", "to",
"under", "use", "using", "various", "we", "what", "when", "where", "which",
"while", "who", "why", "with", "would", "you", "your",
}
LOW_SIGNAL_TERMS = {
"analysis", "approach", "approaches", "based", "compare", "compares",
"comparison", "effect", "effects", "framework", "general", "including",
"method", "methods", "model", "models", "moving", "preserve",
"preserves", "preserving", "problem", "problems", "process", "results",
"same", "structure", "study", "system", "systems", "transition",
"transitions", "various",
}
WALL_KEYWORDS = {
"theoretical": {
"theorem", "proof", "relaxation", "formulation", "branch", "bound",
"semidefinite", "optimization", "manifold", "algorithm", "convergence",
"objective", "quadratic", "mixed-integer", "qubo", "ising",
},
"experimental": {
"experiment", "experimental", "measurement", "empirical", "benchmark",
"prototype", "study", "observed", "measured", "dataset", "validation",
"trial", "evaluation", "performance",
},
"fabrication": {
"fabrication", "synthesis", "material", "materials", "device", "thin",
"film", "mxene", "nanoscroll", "electrode", "deposition", "etching",
"semiconductor", "substrate", "sensor",
},
"scaling": {
"scale", "scaling", "runtime", "complexity", "large-scale", "embedding",
"memory", "qubits", "hardware", "rate-limit", "bottleneck",
"throughput", "latency", "benchmarking",
},
"terminology": {
"survey", "review", "introduction", "overview", "taxonomy", "framework",
"perspective", "tutorial", "terminology", "glossary",
},
}
SOURCE_PRIORITY = {
"semantic_scholar": 4.0,
"google_scholar": 3.0, # scholarly (pip install scholarly); blocked = silent skip
"openalex": 2.5,
"crossref": 1.5,
"arxiv": 1.0,
}
PIPELINE_FLAT = "flat"
PIPELINE_STAGED = "staged"
PIPELINE_COMPARE = "compare"
STAGE_INGEST = "ingest_sieve"
STAGE_BREAKUP = "breakup_normalization"
STAGE_CLASSIFY = "classification_sieve"
STAGE_LIBERATE = "liberation_aggregation"
STAGE_HANDOFF = "recovery_handoff"
DEFAULT_CLASSIFY_WINDOW = 24
DEFAULT_MIN_RELEVANCE = 3.5
@dataclass
class Paper:
title: str
abstract: str
authors: List[str]
year: Optional[int]
venue: str
doi: str
url: str
sources: List[str] = field(default_factory=list)
source_ids: List[str] = field(default_factory=list)
queries: List[str] = field(default_factory=list)
citations: int = 0
fields_of_study: List[str] = field(default_factory=list)
relevance_score: float = 0.0
matched_terms: List[str] = field(default_factory=list)
class ApiFetchError(RuntimeError):
pass
def now_utc() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def normalize_ws(text: str) -> str:
return re.sub(r"\s+", " ", (text or "")).strip()
def normalize_title(text: str) -> str:
text = normalize_ws(text).lower()
return re.sub(r"[^a-z0-9 ]+", "", text)
def safe_int(value: object) -> Optional[int]:
try:
return int(value)
except (TypeError, ValueError):
return None
def slugify(text: str, max_len: int = 80) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
return slug[:max_len] or "probe"
def unique_preserve(items: Iterable[str]) -> List[str]:
seen = set()
out: List[str] = []
for item in items:
key = normalize_ws(item)
if not key:
continue
folded = key.lower()
if folded in seen:
continue
seen.add(folded)
out.append(key)
return out
def text_tokens(text: str) -> List[str]:
return [tok for tok in re.findall(r"[a-z0-9][a-z0-9\-]+", text.lower()) if tok not in STOPWORDS]
def content_terms(text: str, max_terms: int = 8) -> List[str]:
tokens = [tok for tok in text_tokens(text) if len(tok) > 2 and tok not in LOW_SIGNAL_TERMS]
ranked = []
seen = set()
for tok in tokens:
if tok in seen:
continue
seen.add(tok)
ranked.append(tok)
return ranked[:max_terms]
def question_ngrams(question: str) -> List[str]:
terms = content_terms(question, max_terms=12)
ngrams: List[str] = []
for n in (2, 3):
for i in range(0, len(terms) - n + 1):
ngrams.append(" ".join(terms[i : i + n]))
return unique_preserve(ngrams)
def derive_queries(question: str, extra_queries: Sequence[str]) -> List[str]:
queries = [normalize_ws(question)]
keywords = content_terms(question, max_terms=8)
if len(keywords) >= 3:
queries.append(" ".join(keywords))
queries.extend(extra_queries)
return unique_preserve(queries)
def build_user_agent() -> str:
mailto = os.getenv("LITERATURE_PROBE_MAILTO") or os.getenv("CROSSREF_MAILTO")
if mailto:
return f"{BASE_USER_AGENT} (mailto:{mailto})"
return BASE_USER_AGENT
def classify_source_error(message: str) -> str:
lowered = (message or "").lower()
if "429" in lowered or "too many requests" in lowered or "rate limit" in lowered:
return "rate_limited"
if "401" in lowered or "403" in lowered:
return "auth"
if "timed out" in lowered or "timeout" in lowered:
return "timeout"
if lowered:
return "error"
return ""
def fetch_json(url: str, headers: Optional[Dict[str, str]] = None, timeout: float = 30.0) -> dict:
hdrs = {"User-Agent": build_user_agent()}
if headers:
hdrs.update(headers)
req = urllib.request.Request(url, headers=hdrs)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8", errors="replace"))
except urllib.error.HTTPError as exc:
try:
body = exc.read().decode("utf-8", errors="replace")
except Exception:
body = ""
raise ApiFetchError(f"{exc.code} from {url}: {body[:240]}") from exc
except (urllib.error.URLError, OSError, json.JSONDecodeError) as exc:
raise ApiFetchError(f"failed fetching {url}: {exc}") from exc
def fetch_text(url: str, timeout: float = 30.0) -> str:
req = urllib.request.Request(url, headers={"User-Agent": build_user_agent()})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
raise ApiFetchError(f"{exc.code} from {url}") from exc
except (urllib.error.URLError, OSError) as exc:
raise ApiFetchError(f"failed fetching {url}: {exc}") from exc
def abstract_from_openalex_index(index: dict) -> str:
if not index:
return ""
max_pos = 0
for positions in index.values():
for pos in positions:
if pos > max_pos:
max_pos = pos
tokens = [""] * (max_pos + 1)
for token, positions in index.items():
for pos in positions:
if 0 <= pos < len(tokens):
tokens[pos] = token
return normalize_ws(" ".join(tokens))
def paper_key(paper: Paper) -> str:
if paper.doi:
return f"doi:{paper.doi.lower()}"
return f"title:{normalize_title(paper.title)}"
def merge_paper(existing: Paper, incoming: Paper) -> Paper:
if len(incoming.abstract) > len(existing.abstract):
existing.abstract = incoming.abstract
if len(incoming.authors) > len(existing.authors):
existing.authors = incoming.authors
if existing.year is None and incoming.year is not None:
existing.year = incoming.year
if not existing.venue and incoming.venue:
existing.venue = incoming.venue
if not existing.doi and incoming.doi:
existing.doi = incoming.doi
if not existing.url and incoming.url:
existing.url = incoming.url
existing.citations = max(existing.citations, incoming.citations)
existing.sources = unique_preserve(existing.sources + incoming.sources)
existing.source_ids = unique_preserve(existing.source_ids + incoming.source_ids)
existing.queries = unique_preserve(existing.queries + incoming.queries)
existing.fields_of_study = unique_preserve(existing.fields_of_study + incoming.fields_of_study)
return existing
def from_openalex(query: str, limit: int) -> List[Paper]:
params = {"search": query, "per-page": str(limit)}
mailto = os.getenv("LITERATURE_PROBE_MAILTO")
if mailto:
params["mailto"] = mailto
url = "https://api.openalex.org/works?" + urllib.parse.urlencode(params)
data = fetch_json(url)
papers: List[Paper] = []
for item in data.get("results", []):
title = normalize_ws(item.get("display_name", ""))
if not title:
continue
fields = []
primary_topic = (item.get("primary_topic") or {}).get("display_name")
if primary_topic:
fields.append(normalize_ws(primary_topic))
for concept in item.get("concepts", []) or []:
name = normalize_ws(concept.get("display_name", ""))
if name:
fields.append(name)
papers.append(
Paper(
title=title,
abstract=abstract_from_openalex_index(item.get("abstract_inverted_index") or {}),
authors=[
normalize_ws(((auth.get("author") or {}).get("display_name", "")))
for auth in (item.get("authorships") or [])
if normalize_ws(((auth.get("author") or {}).get("display_name", "")))
],
year=safe_int(item.get("publication_year")),
venue=normalize_ws((((item.get("primary_location") or {}).get("source") or {}).get("display_name", ""))),
doi=normalize_ws((item.get("doi") or "").replace("https://doi.org/", "")),
url=normalize_ws(item.get("id", "")),
sources=["openalex"],
source_ids=[normalize_ws(item.get("id", ""))],
queries=[query],
citations=safe_int(item.get("cited_by_count")) or 0,
fields_of_study=unique_preserve(fields),
)
)
return papers
def from_crossref(query: str, limit: int) -> List[Paper]:
params = {
"query": query,
"rows": str(limit),
"select": ",".join(
[
"DOI",
"title",
"author",
"container-title",
"URL",
"published-print",
"published-online",
"issued",
"abstract",
"subject",
"is-referenced-by-count",
]
),
}
mailto = os.getenv("LITERATURE_PROBE_MAILTO") or os.getenv("CROSSREF_MAILTO")
if mailto:
params["mailto"] = mailto
url = "https://api.crossref.org/works?" + urllib.parse.urlencode(params)
data = fetch_json(url)
papers: List[Paper] = []
for item in data.get("message", {}).get("items", []):
title_parts = item.get("title", []) if isinstance(item.get("title", []), list) else [str(item.get("title", ""))]
title = normalize_ws(" ".join(title_parts))
if not title:
continue
year = None
for field_name in ("published-print", "published-online", "issued"):
date_parts = (((item.get(field_name) or {}).get("date-parts") or [[None]])[0] or [None])
year = safe_int(date_parts[0])
if year is not None:
break
abstract = normalize_ws(re.sub(r"<[^>]+>", " ", item.get("abstract", "") or ""))
authors = []
for author in item.get("author", []) or []:
full = normalize_ws(f"{author.get('given', '')} {author.get('family', '')}")
if full:
authors.append(full)
venue_parts = item.get("container-title", []) if isinstance(item.get("container-title", []), list) else [str(item.get("container-title", ""))]
fields = [normalize_ws(subject) for subject in (item.get("subject") or []) if normalize_ws(subject)]
papers.append(
Paper(
title=title,
abstract=abstract,
authors=authors,
year=year,
venue=normalize_ws(" ".join(venue_parts)),
doi=normalize_ws(item.get("DOI", "")),
url=normalize_ws(item.get("URL", "")),
sources=["crossref"],
source_ids=[normalize_ws(item.get("DOI", "") or item.get("URL", ""))],
queries=[query],
citations=safe_int(item.get("is-referenced-by-count")) or 0,
fields_of_study=unique_preserve(fields),
)
)
return papers
def from_semantic_scholar(query: str, limit: int) -> List[Paper]:
params = {
"query": query,
"limit": str(limit),
"fields": ",".join(
[
"title",
"abstract",
"year",
"venue",
"url",
"authors",
"externalIds",
"citationCount",
"fieldsOfStudy",
]
),
}
headers: Dict[str, str] = {}
api_key = os.getenv("SEMANTIC_SCHOLAR_API_KEY")
if api_key:
headers["x-api-key"] = api_key
url = "https://api.semanticscholar.org/graph/v1/paper/search?" + urllib.parse.urlencode(params)
data = fetch_json(url, headers=headers)
papers: List[Paper] = []
for item in data.get("data", []):
title = normalize_ws(item.get("title", ""))
if not title:
continue
external_ids = item.get("externalIds") or {}
doi = normalize_ws(external_ids.get("DOI", ""))
fields = [normalize_ws(field) for field in (item.get("fieldsOfStudy") or []) if normalize_ws(field)]
papers.append(
Paper(
title=title,
abstract=normalize_ws(item.get("abstract", "")),
authors=[normalize_ws(author.get("name", "")) for author in (item.get("authors") or []) if normalize_ws(author.get("name", ""))],
year=safe_int(item.get("year")),
venue=normalize_ws(item.get("venue", "")),
doi=doi,
url=normalize_ws(item.get("url", "")),
sources=["semantic_scholar"],
source_ids=[normalize_ws(item.get("paperId", "") or item.get("url", ""))],
queries=[query],
citations=safe_int(item.get("citationCount")) or 0,
fields_of_study=unique_preserve(fields),
)
)
return papers
def from_arxiv(query: str, limit: int) -> List[Paper]:
q = urllib.parse.quote(query)
url = f"https://export.arxiv.org/api/query?search_query=all:{q}&start=0&max_results={limit}"
text = fetch_text(url)
ns = {"atom": "http://www.w3.org/2005/Atom", "arxiv": "http://arxiv.org/schemas/atom"}
root = ET.fromstring(text)
papers: List[Paper] = []
for entry in root.findall("atom:entry", ns):
title = normalize_ws(entry.findtext("atom:title", default="", namespaces=ns) or "")
if not title:
continue
doi = ""
doi_node = entry.find("arxiv:doi", ns)
if doi_node is not None and (doi_node.text or "").strip():
doi = normalize_ws(doi_node.text)
papers.append(
Paper(
title=title,
abstract=normalize_ws(entry.findtext("atom:summary", default="", namespaces=ns) or ""),
authors=[
normalize_ws(author.findtext("atom:name", default="", namespaces=ns) or "")
for author in entry.findall("atom:author", ns)
if normalize_ws(author.findtext("atom:name", default="", namespaces=ns) or "")
],
year=safe_int((entry.findtext("atom:published", default="", namespaces=ns) or "")[:4]),
venue="arXiv",
doi=doi,
url=normalize_ws(entry.findtext("atom:id", default="", namespaces=ns) or ""),
sources=["arxiv"],
source_ids=[normalize_ws(entry.findtext("atom:id", default="", namespaces=ns) or "")],
queries=[query],
citations=0,
fields_of_study=["preprint"],
)
)
return papers
def from_google_scholar(query: str, limit: int) -> List[Paper]:
"""Fetch from Google Scholar via the `scholarly` library (pip install scholarly).
scholarly is an unofficial scraper — Google blocks with CAPTCHAs under
heavy load. This function returns [] silently when:
- scholarly is not installed
- Google returns a CAPTCHA / rate-limit
No proxy is configured; install scholarly and run at low request rates.
"""
try:
import itertools
import scholarly as _scholarly # type: ignore
except ImportError:
return []
papers: List[Paper] = []
try:
iterator = _scholarly.search_pubs(query)
for result in itertools.islice(iterator, limit):
bib = result.get("bib") or {}
title = normalize_ws(bib.get("title", ""))
if not title:
continue
raw_authors = bib.get("author", "")
if isinstance(raw_authors, list):
authors = [normalize_ws(a) for a in raw_authors if normalize_ws(a)]
else:
authors = [normalize_ws(a) for a in re.split(r"\s+and\s+|,\s*", raw_authors) if normalize_ws(a)]
year_raw = bib.get("pub_year", "") or bib.get("year", "")
venue = normalize_ws(bib.get("venue", "") or bib.get("journal", "") or bib.get("booktitle", ""))
url = normalize_ws(result.get("pub_url", "") or result.get("eprint_url", ""))
cites = safe_int(result.get("num_citations")) or 0
papers.append(
Paper(
title=title,
abstract=normalize_ws(bib.get("abstract", "")),
authors=authors,
year=safe_int(str(year_raw)[:4]) if year_raw else None,
venue=venue,
doi="",
url=url,
sources=["google_scholar"],
source_ids=[url or title[:80]],
queries=[query],
citations=cites,
fields_of_study=[],
)
)
except Exception:
# CAPTCHA, network error, or scholarly internal error — return what we have
pass
return papers
def dedupe_and_merge(papers: Sequence[Paper]) -> List[Paper]:
merged: Dict[str, Paper] = {}
for paper in papers:
key = paper_key(paper)
if key in merged:
merged[key] = merge_paper(merged[key], paper)
else:
merged[key] = paper
return list(merged.values())
def score_paper(paper: Paper, question: str) -> Tuple[float, List[str]]:
question_terms = set(content_terms(question, max_terms=12))
paper_title_terms = set(text_tokens(paper.title))
paper_body_terms = set(text_tokens(f"{paper.title} {paper.abstract} {' '.join(paper.fields_of_study)} {paper.venue}"))
overlaps = sorted(question_terms & paper_body_terms)
q_ngrams = question_ngrams(question)
haystack = f"{paper.title} {paper.abstract}".lower()
phrase_hits = sum(1 for ngram in q_ngrams if ngram and ngram in haystack)
score = 0.0
score += 3.0 * len(question_terms & paper_title_terms)
score += 1.2 * len(overlaps)
score += 2.5 * phrase_hits
if paper.abstract:
score += 1.0
if paper.citations > 0:
score += min(4.0, math.log10(paper.citations + 1) * 1.5)
score += source_preference_score(paper)
return round(score, 3), overlaps
def source_preference_score(paper: Paper) -> float:
if not paper.sources:
return 0.0
max_source = max(SOURCE_PRIORITY.get(source, 0.0) for source in paper.sources)
diversity_bonus = min(0.6, 0.15 * max(0, len(paper.sources) - 1))
return max_source * 0.25 + diversity_bonus
def stage_record(
name: str,
input_count: int,
output_count: int,
note: str,
metrics: Optional[Dict[str, object]] = None,
) -> dict:
row = {
"stage": name,
"input_count": input_count,
"output_count": output_count,
"note": note,
}
if metrics:
row["metrics"] = metrics
return row
def average_relevance(papers: Sequence[Paper], top_n: int = 12) -> float:
window = list(papers[:top_n])
if not window:
return 0.0
return round(sum(p.relevance_score for p in window) / len(window), 3)
def average_matched_terms(papers: Sequence[Paper], top_n: int = 12) -> float:
window = list(papers[:top_n])
if not window:
return 0.0
return round(sum(len(p.matched_terms) for p in window) / len(window), 3)
def select_candidate_window(
ranked: Sequence[Paper],
classify_window: int,
min_relevance: float,
) -> List[Paper]:
capped = max(1, classify_window)
kept = [paper for paper in ranked if paper.relevance_score >= min_relevance]
if kept:
return kept[:capped]
return list(ranked[:capped])
def rank_papers(papers: Sequence[Paper], question: str) -> List[Paper]:
ranked: List[Paper] = []
for paper in papers:
score, overlaps = score_paper(paper, question)
paper.relevance_score = score
paper.matched_terms = overlaps
ranked.append(paper)
ranked.sort(key=lambda p: (-p.relevance_score, -(p.citations or 0), -(p.year or 0), p.title.lower()))
return ranked
def recurring_terms(papers: Sequence[Paper], question: str, top_k: int = 12) -> List[str]:
question_terms = set(content_terms(question, max_terms=12))
counts: Counter[str] = Counter()
for paper in papers[:12]:
tokens = [tok for tok in text_tokens(f"{paper.title} {paper.abstract}") if len(tok) > 2]
for n in (1, 2, 3):
for i in range(0, len(tokens) - n + 1):
phrase = " ".join(tokens[i : i + n])
parts = phrase.split()
if all(part in question_terms for part in parts):
continue
if any(part in STOPWORDS for part in parts):
continue
counts[phrase] += 1
ranked = [term for term, count in counts.most_common(top_k * 4) if count >= 2]
ranked.sort(key=lambda term: (-len(term.split()), -counts[term], term))
return ranked[:top_k]
def nearby_fields(papers: Sequence[Paper], top_k: int = 10) -> List[str]:
counts: Counter[str] = Counter()
for paper in papers[:20]:
for field_name in paper.fields_of_study:
counts[normalize_ws(field_name)] += 1
return [field_name for field_name, _ in counts.most_common(top_k)]
def source_contribution_summary(papers: Sequence[Paper], top_n: int = 12) -> Dict[str, int]:
counts: Counter[str] = Counter()
for paper in papers[:top_n]:
for source in paper.sources:
counts[source] += 1
return dict(counts.most_common())
def wall_signals(papers: Sequence[Paper], question: str) -> Dict[str, float]:
if not papers:
return {"evidence_gap": 1.0}
corpus = " ".join(
normalize_ws(
" ".join(
[
paper.title,
paper.abstract,
paper.venue,
" ".join(paper.fields_of_study),
]
)
).lower()
for paper in papers[:12]
)
scores: Dict[str, float] = {}
for wall_type, keywords in WALL_KEYWORDS.items():
score = 0.0
for keyword in keywords:
if keyword in corpus:
score += 1.0
scores[wall_type] = score
avg_relevance = sum(p.relevance_score for p in papers[:8]) / max(1, min(len(papers), 8))
if len(papers) < 5 or avg_relevance < 4.0:
scores["evidence_gap"] = scores.get("evidence_gap", 0.0) + 3.0
question_terms = set(content_terms(question, max_terms=12))
overlap_total = sum(len(p.matched_terms) for p in papers[:8])
if papers and overlap_total <= max(2, len(question_terms) // 2):
scores["terminology"] = scores.get("terminology", 0.0) + 2.0
return scores
def likely_wall_type(papers: Sequence[Paper], question: str) -> Tuple[str, Dict[str, float]]:
scores = wall_signals(papers, question)
if not scores:
return "evidence_gap", {"evidence_gap": 1.0}
wall_type = max(scores, key=lambda key: scores[key])
rounded = {key: round(value, 3) for key, value in sorted(scores.items())}
return wall_type, rounded
def to_json_ready(papers: Sequence[Paper]) -> List[dict]:
rows = []
for paper in papers:
rows.append(asdict(paper))
return rows
def markdown_summary(
question: str,
queries: Sequence[str],
wall_type: str,
wall_scores: Dict[str, float],
source_stats: Dict[str, dict],
source_contributions: Dict[str, int],
recurring: Sequence[str],
fields: Sequence[str],
papers: Sequence[Paper],
pipeline_mode: str = PIPELINE_FLAT,
stage_trace: Optional[Sequence[dict]] = None,
) -> str:
lines = [
f"# Literature Probe",
"",
f"**Question:** {question}",
"",
f"**Pipeline:** `{pipeline_mode}`",
"",
f"**Likely wall type:** `{wall_type}`",
"",
"## Queries",
"",
]
for query in queries:
lines.append(f"- `{query}`")
if stage_trace:
lines.extend(["", "## Stage Trace", ""])
for stage in stage_trace:
line = (
f"- `{stage.get('stage', '')}`: "
f"{stage.get('input_count', 0)} -> {stage.get('output_count', 0)}"
)
note = normalize_ws(str(stage.get("note", "")))
if note:
line += f" | {note}"
lines.append(line)
lines.extend(["", "## Wall Signals", ""])
for key, value in sorted(wall_scores.items(), key=lambda item: (-item[1], item[0])):
lines.append(f"- `{key}`: {value}")
lines.extend(["", "## Source Stats", ""])
for source_name, stats in source_stats.items():
line = f"- `{source_name}`: {stats.get('count', 0)} papers"
if stats.get("status"):
line += f" | status: {stats['status']}"
if stats.get("error"):
line += f" | error: {stats['error']}"
lines.append(line)
if source_contributions:
lines.extend(["", "## Source Contribution In Top Papers", ""])
for source_name, count in source_contributions.items():
lines.append(f"- `{source_name}`: {count} of top 12")
if recurring:
lines.extend(["", "## Recurring Terms", ""])
for term in recurring:
lines.append(f"- `{term}`")
if fields:
lines.extend(["", "## Nearby Fields", ""])
for field_name in fields:
lines.append(f"- `{field_name}`")
lines.extend(["", "## Top Papers", ""])
for index, paper in enumerate(papers[:12], start=1):
authors = ", ".join(paper.authors[:4])
details = []
if paper.year:
details.append(str(paper.year))
if paper.venue:
details.append(paper.venue)
if paper.sources:
details.append("sources: " + "/".join(paper.sources))
if paper.citations:
details.append(f"citations: {paper.citations}")
if paper.matched_terms:
details.append("matched: " + ", ".join(paper.matched_terms[:6]))
detail_text = " | ".join(details)
lines.append(f"{index}. [{paper.title}]({paper.url or paper.doi or '#'})")
if detail_text:
lines.append(f" {detail_text}")
if authors:
lines.append(f" {authors}")
lines.append("")
return "\n".join(lines)
def markdown_compare_summary(result: dict) -> str:
comparison = result["comparison"]
flat = result["flat_result"]
staged = result["staged_result"]
lines = [
"# Literature Probe Pipeline Comparison",
"",
f"**Question:** {result['question']}",
"",
"## Summary",
"",
f"- Flat top-12 average relevance: `{comparison['flat_top12_avg_relevance']}`",
f"- Staged top-12 average relevance: `{comparison['staged_top12_avg_relevance']}`",
f"- Flat final paper count: `{comparison['flat_final_paper_count']}`",
f"- Staged final paper count: `{comparison['staged_final_paper_count']}`",
f"- Staged burden reduction vs flat: `{comparison['staged_burden_reduction_ratio']}`",
f"- Top-12 overlap count: `{comparison['top12_overlap_count']}`",
"",
"## Flat Stage Trace",
"",
]
for stage in flat.get("stage_trace", []):
lines.append(
f"- `{stage.get('stage', '')}`: {stage.get('input_count', 0)} -> {stage.get('output_count', 0)}"
)
lines.extend(["", "## Staged Stage Trace", ""])
for stage in staged.get("stage_trace", []):
lines.append(
f"- `{stage.get('stage', '')}`: {stage.get('input_count', 0)} -> {stage.get('output_count', 0)}"
)
lines.extend(["", "## Top-12 Overlap Titles", ""])
for title in comparison.get("top12_overlap_titles", []):
lines.append(f"- {title}")
if not comparison.get("top12_overlap_titles"):
lines.append("- none")
lines.extend(["", "## Interpretation", ""])
lines.append(comparison["interpretation"])
lines.append("")
return "\n".join(lines)
def fetch_all_papers(
queries: Sequence[str],
per_source: int,
) -> Tuple[List[Paper], Dict[str, dict], List[dict]]:
all_papers: List[Paper] = []
source_stats: Dict[str, dict] = {}
events: List[dict] = []
fetchers = {
"semantic_scholar": from_semantic_scholar,
"openalex": from_openalex,
"crossref": from_crossref,
"arxiv": from_arxiv,
}
try:
import scholarly # noqa: F401
fetchers["google_scholar"] = from_google_scholar
except ImportError:
pass
for source_name, fetcher in fetchers.items():
source_total = 0
error_text = ""
status = "ok"
for query in queries:
try:
results = fetcher(query, per_source)
except ApiFetchError as exc:
error_text = str(exc)
status = classify_source_error(error_text)
events.append(
{
"source": source_name,
"query": query,
"status": status,
"message": error_text,
}
)
continue
source_total += len(results)
all_papers.extend(results)
time.sleep(0.15)
source_stats[source_name] = {"count": source_total, "error": error_text, "status": status}
return all_papers, source_stats, events
def build_probe_result(
*,
question: str,
queries: Sequence[str],
source_stats: Dict[str, dict],
events: List[dict],
wall_type: str,
wall_scores: Dict[str, float],
recurring: Sequence[str],
fields: Sequence[str],
source_contributions: Dict[str, int],
papers: Sequence[Paper],
pipeline_mode: str,
stage_trace: Sequence[dict],
raw_hit_count: int,
deduped_paper_count: int,
classified_paper_count: int,
final_paper_count: int,
classify_window: int,
min_relevance: float,
candidate_selection_note: str,
) -> dict:
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"question": question,
"queries": list(queries),
"pipeline_mode": pipeline_mode,
"stage_trace": list(stage_trace),
"wall_type": wall_type,
"wall_scores": wall_scores,
"recurring_terms": list(recurring),
"nearby_fields": list(fields),
"source_stats": source_stats,
"source_contributions_top12": source_contributions,
"events": events,
"raw_hit_count": raw_hit_count,
"deduped_paper_count": deduped_paper_count,
"classified_paper_count": classified_paper_count,
"final_paper_count": final_paper_count,
"top12_avg_relevance": average_relevance(papers),
"top12_avg_matched_terms": average_matched_terms(papers),
"classify_window": classify_window,
"min_relevance": min_relevance,
"candidate_selection_note": candidate_selection_note,
"papers": to_json_ready(papers),
}
def run_probe_flat(
question: str,
extra_queries: Sequence[str],
per_source: int,
classify_window: int = DEFAULT_CLASSIFY_WINDOW,
min_relevance: float = DEFAULT_MIN_RELEVANCE,
) -> dict:
queries = derive_queries(question, extra_queries)
all_papers, source_stats, events = fetch_all_papers(queries, per_source)
deduped = dedupe_and_merge(all_papers)
ranked = rank_papers(deduped, question)
wall_type, wall_scores = likely_wall_type(ranked, question)
recurring = recurring_terms(ranked, question)
fields = nearby_fields(ranked)
source_contributions = source_contribution_summary(ranked)
top_window = min(12, len(ranked))
stage_trace = [
stage_record(
STAGE_INGEST,
len(queries),
len(all_papers),
"Fetched raw candidate hits across all configured scholarly sources.",
{"query_count": len(queries), "per_source": per_source},
),
stage_record(
STAGE_BREAKUP,
len(all_papers),
len(deduped),
"Normalized and deduplicated records into one comparable candidate set.",
),
stage_record(
STAGE_CLASSIFY,
len(deduped),
len(ranked),
"Ranked all deduplicated candidates without narrowing the result window.",
{"selection_mode": "flat_full_rank"},
),
stage_record(
STAGE_LIBERATE,
len(ranked),
top_window,
"Derived recurring terms, nearby fields, and wall signals from the top-ranked set.",
),
stage_record(
STAGE_HANDOFF,
top_window,
len(ranked),
"Returned the full ranked list as the flat baseline handoff surface.",
),
]
return build_probe_result(
question=question,
queries=queries,
source_stats=source_stats,
events=events,
wall_type=wall_type,
wall_scores=wall_scores,
recurring=recurring,
fields=fields,
source_contributions=source_contributions,
papers=ranked,
pipeline_mode=PIPELINE_FLAT,
stage_trace=stage_trace,
raw_hit_count=len(all_papers),
deduped_paper_count=len(deduped),
classified_paper_count=len(ranked),
final_paper_count=len(ranked),
classify_window=classify_window,
min_relevance=min_relevance,
candidate_selection_note="Flat baseline keeps the full ranked list after scoring.",
)
def run_probe_staged(
question: str,
extra_queries: Sequence[str],
per_source: int,
classify_window: int = DEFAULT_CLASSIFY_WINDOW,
min_relevance: float = DEFAULT_MIN_RELEVANCE,
) -> dict:
queries = derive_queries(question, extra_queries)
all_papers, source_stats, events = fetch_all_papers(queries, per_source)
deduped = dedupe_and_merge(all_papers)
ranked = rank_papers(deduped, question)
candidates = select_candidate_window(ranked, classify_window, min_relevance)
wall_type, wall_scores = likely_wall_type(candidates, question)
recurring = recurring_terms(candidates, question)
fields = nearby_fields(candidates)
source_contributions = source_contribution_summary(candidates)
stage_trace = [
stage_record(
STAGE_INGEST,
len(queries),
len(all_papers),
"Fetched raw candidate hits across all configured scholarly sources.",
{"query_count": len(queries), "per_source": per_source},
),
stage_record(
STAGE_BREAKUP,
len(all_papers),
len(deduped),
"Normalized and deduplicated records into one comparable candidate set.",
),
stage_record(
STAGE_CLASSIFY,
len(deduped),
len(candidates),
"Scored candidates and kept only the bounded relevance window.",
{"classify_window": max(1, classify_window), "min_relevance": min_relevance},
),
stage_record(
STAGE_LIBERATE,
len(candidates),
min(12, len(candidates)),
"Computed recurring terms, nearby fields, and wall signals only from the retained relevance slice.",
),
stage_record(
STAGE_HANDOFF,
min(12, len(candidates)),
len(candidates),
"Returned the narrowed candidate slice as the staged handoff surface.",
),
]
return build_probe_result(
question=question,
queries=queries,
source_stats=source_stats,
events=events,
wall_type=wall_type,
wall_scores=wall_scores,
recurring=recurring,
fields=fields,
source_contributions=source_contributions,
papers=candidates,
pipeline_mode=PIPELINE_STAGED,
stage_trace=stage_trace,
raw_hit_count=len(all_papers),
deduped_paper_count=len(deduped),
classified_paper_count=len(candidates),
final_paper_count=len(candidates),
classify_window=classify_window,
min_relevance=min_relevance,
candidate_selection_note=(
"Staged mode keeps only the bounded relevance window after classification "
"so later aggregation and handoff operate on a smaller, more explainable slice."
),
)
def compare_probe_results(flat_result: dict, staged_result: dict) -> dict:
flat_papers = [Paper(**paper) for paper in flat_result["papers"]]
staged_papers = [Paper(**paper) for paper in staged_result["papers"]]
flat_top = flat_papers[:12]
staged_top = staged_papers[:12]
flat_titles = {normalize_title(p.title): p.title for p in flat_top}
staged_titles = {normalize_title(p.title): p.title for p in staged_top}
overlap_keys = sorted(set(flat_titles) & set(staged_titles))
overlap_titles = [flat_titles[key] for key in overlap_keys]
flat_final = max(1, flat_result.get("final_paper_count", len(flat_papers)))
staged_final = staged_result.get("final_paper_count", len(staged_papers))
burden_ratio = round(staged_final / flat_final, 3)
staged_gain = round(
staged_result.get("top12_avg_relevance", 0.0) - flat_result.get("top12_avg_relevance", 0.0),
3,
)
if staged_gain > 0:
interpretation = (
"The staged path kept a smaller candidate surface while slightly increasing "
"top-result relevance density."
)
elif staged_gain < 0:
interpretation = (
"The staged path reduced burden, but it also lowered top-result relevance density "
"for this query and should be tuned."
)
else:
interpretation = (
"The staged path reduced burden without materially changing top-result relevance density "
"for this query."
)
return {
"flat_top12_avg_relevance": flat_result.get("top12_avg_relevance", 0.0),
"staged_top12_avg_relevance": staged_result.get("top12_avg_relevance", 0.0),
"flat_top12_avg_matched_terms": flat_result.get("top12_avg_matched_terms", 0.0),
"staged_top12_avg_matched_terms": staged_result.get("top12_avg_matched_terms", 0.0),
"flat_final_paper_count": flat_result.get("final_paper_count", len(flat_papers)),
"staged_final_paper_count": staged_result.get("final_paper_count", len(staged_papers)),
"staged_burden_reduction_ratio": burden_ratio,
"top12_overlap_count": len(overlap_titles),
"top12_overlap_titles": overlap_titles,
"interpretation": interpretation,
}
def run_probe(
question: str,
extra_queries: Sequence[str],
per_source: int,
pipeline_mode: str = PIPELINE_FLAT,
classify_window: int = DEFAULT_CLASSIFY_WINDOW,
min_relevance: float = DEFAULT_MIN_RELEVANCE,
) -> dict:
if pipeline_mode == PIPELINE_STAGED:
return run_probe_staged(question, extra_queries, per_source, classify_window, min_relevance)
if pipeline_mode == PIPELINE_COMPARE:
flat_result = run_probe_flat(question, extra_queries, per_source, classify_window, min_relevance)
staged_result = run_probe_staged(question, extra_queries, per_source, classify_window, min_relevance)
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"question": question,
"pipeline_mode": PIPELINE_COMPARE,
"flat_result": flat_result,
"staged_result": staged_result,
"comparison": compare_probe_results(flat_result, staged_result),
}
return run_probe_flat(question, extra_queries, per_source, classify_window, min_relevance)
def write_outputs(result: dict, out_dir: Path) -> Tuple[Path, Path]:
out_dir.mkdir(parents=True, exist_ok=True)
stamp = now_utc()
pipeline_mode = result.get("pipeline_mode", PIPELINE_FLAT)
stem = f"{stamp}_{slugify(result['question'])}_{pipeline_mode}"
json_path = out_dir / f"{stem}.json"
md_path = out_dir / f"{stem}.md"
json_path.write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8")
if pipeline_mode == PIPELINE_COMPARE:
md_path.write_text(markdown_compare_summary(result), encoding="utf-8")
else:
papers = [Paper(**paper) for paper in result["papers"]]
md_path.write_text(
markdown_summary(
question=result["question"],
queries=result["queries"],
wall_type=result["wall_type"],
wall_scores=result["wall_scores"],
source_stats=result["source_stats"],
source_contributions=result["source_contributions_top12"],
recurring=result["recurring_terms"],
fields=result["nearby_fields"],
papers=papers,
pipeline_mode=pipeline_mode,
stage_trace=result.get("stage_trace"),
),
encoding="utf-8",
)
return json_path, md_path
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Probe scholarly literature around one research question.")
parser.add_argument("question", help="Research question to probe.")
parser.add_argument(
"--extra-query",
action="append",
default=[],
help="Optional extra focused search query. Can be used multiple times.",
)
parser.add_argument(
"--per-source",
type=int,
default=8,
help="Number of results to request from each source for each query.",
)
parser.add_argument(
"--pipeline",
choices=[PIPELINE_FLAT, PIPELINE_STAGED, PIPELINE_COMPARE],
default=PIPELINE_FLAT,
help="Choose the retrieval/compression posture to run.",
)
parser.add_argument(
"--classify-window",
type=int,
default=DEFAULT_CLASSIFY_WINDOW,
help="Maximum number of candidates retained after the classification sieve in staged mode.",
)
parser.add_argument(
"--min-relevance",
type=float,
default=DEFAULT_MIN_RELEVANCE,
help="Minimum relevance score to keep during the classification sieve in staged mode.",
)
parser.add_argument(
"--out-dir",
default=str(DEFAULT_OUT_DIR),
help="Directory to write JSON and Markdown outputs.",
)
parser.add_argument(
"--print-json",
action="store_true",
help="Also print the final JSON result to stdout.",
)
return parser
def main(argv: Optional[Sequence[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
result = run_probe(
args.question,
args.extra_query,
max(1, args.per_source),
pipeline_mode=args.pipeline,
classify_window=max(1, args.classify_window),
min_relevance=args.min_relevance,
)
json_path, md_path = write_outputs(result, Path(args.out_dir))
print(f"Question: {result['question']}")
print(f"Pipeline: {result.get('pipeline_mode', PIPELINE_FLAT)}")
if result.get("pipeline_mode") == PIPELINE_COMPARE:
comparison = result["comparison"]
print(f"Flat top-12 avg relevance: {comparison['flat_top12_avg_relevance']}")
print(f"Staged top-12 avg relevance: {comparison['staged_top12_avg_relevance']}")
print(f"Top-12 overlap count: {comparison['top12_overlap_count']}")
print(f"Staged burden reduction ratio: {comparison['staged_burden_reduction_ratio']}")
else:
print(f"Likely wall type: {result['wall_type']}")
print(f"Final paper count: {result.get('final_paper_count', len(result.get('papers', [])))}")
print(f"Wrote JSON: {json_path}")
print(f"Wrote Markdown: {md_path}")
source_stats = (
result["flat_result"]["source_stats"]
if result.get("pipeline_mode") == PIPELINE_COMPARE
else result["source_stats"]
)
if source_stats.get("semantic_scholar", {}).get("status") == "rate_limited":
print("Semantic Scholar status: rate-limited; using fallback sources for this run")
else:
print("Semantic Scholar status:", source_stats.get("semantic_scholar", {}).get("status", "unknown"))
recurring_terms = (
result["staged_result"]["recurring_terms"]
if result.get("pipeline_mode") == PIPELINE_COMPARE
else result["recurring_terms"]
)
nearby_fields_list = (
result["staged_result"]["nearby_fields"]
if result.get("pipeline_mode") == PIPELINE_COMPARE
else result["nearby_fields"]
)
print("Top recurring terms:", ", ".join(recurring_terms[:8]) or "none")
print("Nearby fields:", ", ".join(nearby_fields_list[:8]) or "none")
if args.print_json:
print(json.dumps(result, indent=2, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())