| from __future__ import annotations |
|
|
| import html |
| import os |
| import re |
| import csv |
| import itertools |
| import json |
| import textwrap |
| import time |
| import tempfile |
| import uuid |
| import xml.etree.ElementTree as ET |
| import zipfile |
| from collections import Counter |
| from concurrent.futures import ThreadPoolExecutor, as_completed |
| from dataclasses import dataclass |
| from difflib import SequenceMatcher |
| from pathlib import Path |
| from typing import Any |
|
|
| import gradio as gr |
| import requests |
|
|
| |
| |
| |
| try: |
| from dotenv import load_dotenv |
|
|
| load_dotenv() |
| except ImportError: |
| pass |
|
|
| APP_TITLE = "Scholar Lens" |
| MODEL_ID = "nvidia/Llama-3.1-Nemotron-Nano-8B-v1" |
| MODEL_DISPLAY_NAME = "NVIDIA Llama-Nemotron-Nano 8B" |
| MODEL_PROVIDER_BADGE = "Powered by NVIDIA Nemotron on Modal" |
| SEARCH_LIMIT_PER_SOURCE = 10 |
| CONSTELLATION_OPENALEX_LIMIT = 200 |
| CONSTELLATION_MAX_EDGES = 700 |
| REQUEST_TIMEOUT_SECONDS = 15 |
| REQUEST_RETRY_ATTEMPTS = 3 |
| REQUEST_RETRY_BACKOFF_SECONDS = 0.6 |
| RETRY_STATUS_CODES = {429, 500, 502, 503, 504} |
| |
| |
| CONTACT_EMAIL = os.getenv("SCHOLAR_LENS_CONTACT_EMAIL", "scholar-lens@example.com").strip() |
| OPENALEX_API_KEY = os.getenv("OPENALEX_API_KEY", "").strip() |
| MODAL_SUMMARIZE_URL = os.getenv("MODAL_SUMMARIZE_URL", "").strip() |
| MODAL_SYNTHESIZE_URL = os.getenv("MODAL_SYNTHESIZE_URL", "").strip() |
| MODAL_API_TOKEN = os.getenv("SCHOLAR_LENS_MODAL_TOKEN", "").strip() |
| |
| |
| SEMANTIC_SCHOLAR_API_KEY = os.getenv("SEMANTIC_SCHOLAR_API_KEY", "").strip() |
| |
| SYNTHESIS_PAPER_COUNT = 6 |
| |
| RESULTS_PER_PAGE = 10 |
| |
| MAX_ABSTRACT_CHARS = 1400 |
| MAX_ABSTRACT_TOKENS = 350 |
| SEARCH_QUERY_CHAR_LIMIT = 300 |
| MAX_SEARCH_KEYWORDS = 6 |
| ASK_QUESTION_CHAR_LIMIT = 1200 |
| ASK_QUESTION_TOKEN_LIMIT = 300 |
| SUMMARY_INPUT_CHAR_LIMIT = 60000 |
| SUMMARY_INPUT_TOKEN_LIMIT = 15000 |
| SYNTHESIS_CONTEXT_CHAR_LIMIT = 28000 |
| SYNTHESIS_CONTEXT_TOKEN_LIMIT = 7000 |
| DEFAULT_ASK_ANSWER = "Your grounded, cited answer will appear here." |
| DEFAULT_LOAD_STATUS = "Select a paper to summarize." |
| DEFAULT_PAPER_CHAT_ANSWER = "Ask a question about the selected or pasted paper." |
| DEFAULT_COMPARE_ANSWER = "Search papers, choose any two, then compare them with NVIDIA Nemotron." |
| TOKEN_PATTERN = re.compile(r"\w+|[^\w\s]") |
| FUZZY_TITLE_THRESHOLD = 0.94 |
| EXPORT_DIR = Path(tempfile.gettempdir()) / "scholar-lens-exports" |
| SAMPLE_QUESTIONS = [ |
| "Where do papers disagree on aerosol-cloud interaction uncertainty?", |
| "How is machine learning improving satellite precipitation estimates?", |
| "What are recent methods for detecting atmospheric rivers?", |
| "How do climate models represent urban heat island effects?", |
| ] |
| RUBRIC_PROOF_POINTS = [ |
| ( |
| "Real professor workflow", |
| "Built around an atmospheric-science literature-review pain point, not a generic paper search demo.", |
| ), |
| ( |
| "Adoption proof", |
| "Validated with a real atmospheric-science researcher using their own research questions.", |
| ), |
| ( |
| "NVIDIA Nemotron fit", |
| f"{MODEL_DISPLAY_NAME} only synthesizes retrieved abstracts, so the model is load-bearing without needing broad world knowledge.", |
| ), |
| ( |
| "Product polish", |
| "Ask is the default path, with cited answers, source links, pagination, and friendly empty/error states.", |
| ), |
| ] |
| SEARCH_STOPWORDS = { |
| "a", |
| "about", |
| "and", |
| "are", |
| "as", |
| "be", |
| "being", |
| "between", |
| "by", |
| "can", |
| "does", |
| "for", |
| "from", |
| "how", |
| "in", |
| "into", |
| "is", |
| "main", |
| "of", |
| "on", |
| "or", |
| "recent", |
| "show", |
| "the", |
| "their", |
| "these", |
| "to", |
| "used", |
| "using", |
| "what", |
| "where", |
| "which", |
| "with", |
| } |
| GRAPH_STOPWORDS = SEARCH_STOPWORDS | { |
| "analysis", |
| "based", |
| "brain", |
| "connectivity", |
| "data", |
| "different", |
| "functional", |
| "human", |
| "model", |
| "models", |
| "network", |
| "networks", |
| "paper", |
| "study", |
| "using", |
| } |
| JEWEL_COLORS = [ |
| "#48b6ff", |
| "#ff5fb7", |
| "#66e28f", |
| "#ffd166", |
| "#9b8cff", |
| "#ff7a59", |
| "#3ee8d4", |
| "#f55f8d", |
| "#a6e35f", |
| "#f0f4ff", |
| ] |
| EDGE_TYPE_PRIORITY = { |
| "direct_citation": 0, |
| "bibliographic_coupling": 1, |
| "co_citation": 2, |
| "topic_similarity": 3, |
| "keyword_cooccurrence": 4, |
| } |
|
|
|
|
| @dataclass(frozen=True) |
| class PaperResult: |
| title: str |
| year: str |
| source: str |
| authors: str |
| citations: str |
| url: str |
| abstract: str = "" |
| doi: str = "" |
|
|
|
|
| def _safe_text(value: Any, fallback: str = "Unknown") -> str: |
| text = str(value).strip() if value is not None else "" |
| return text or fallback |
|
|
|
|
| def get_first_author(authors: str | list[str] | tuple[str, ...] | None) -> str: |
| """Return a stable first-author label from either API strings or lists.""" |
| if isinstance(authors, (list, tuple)): |
| for author in authors: |
| clean = _safe_text(author, "") |
| if clean: |
| return clean |
| return "Unknown" |
| text = _safe_text(authors, "") |
| if not text: |
| return "Unknown" |
| first = re.split(r",|;|\bet al\.?", text, maxsplit=1, flags=re.IGNORECASE)[0].strip() |
| return first or "Unknown" |
|
|
|
|
| def _rough_token_count(text: str) -> int: |
| return len(TOKEN_PATTERN.findall(text or "")) |
|
|
|
|
| def _trim_to_budget(text: str, max_chars: int, max_tokens: int) -> str: |
| trimmed = (text or "").strip() |
| if len(trimmed) > max_chars: |
| trimmed = trimmed[:max_chars].rstrip() |
| while _rough_token_count(trimmed) > max_tokens and trimmed: |
| next_length = max(1, int(len(trimmed) * 0.85)) |
| trimmed = trimmed[:next_length].rsplit(" ", 1)[0].rstrip() |
| return trimmed |
|
|
|
|
| def _text_limit_error( |
| text: str, |
| label: str, |
| max_chars: int, |
| max_tokens: int, |
| ) -> str | None: |
| if len(text) > max_chars: |
| return ( |
| f"{label} is too long for this demo. Please keep it under " |
| f"{max_chars:,} characters." |
| ) |
| token_count = _rough_token_count(text) |
| if token_count > max_tokens: |
| return ( |
| f"{label} is too long for this demo. Please keep it under roughly " |
| f"{max_tokens:,} tokens; this input is about {token_count:,} tokens." |
| ) |
| return None |
|
|
|
|
| def _search_terms(query: str) -> list[str]: |
| words = re.findall(r"[a-zA-Z][a-zA-Z0-9-]{2,}", query.lower()) |
| terms: list[str] = [] |
| seen: set[str] = set() |
| for word in words: |
| if word in SEARCH_STOPWORDS or word in seen: |
| continue |
| seen.add(word) |
| terms.append(word) |
| return terms |
|
|
|
|
| def _split_search_queries(query: str) -> list[str]: |
| """Accept one topic or several comma/newline/semicolon separated keyword phrases.""" |
| parts = re.split(r"[\n;,]+", query or "") |
| queries: list[str] = [] |
| seen: set[str] = set() |
| for part in parts: |
| clean = " ".join(part.split()) |
| if not clean: |
| continue |
| key = clean.lower() |
| if key in seen: |
| continue |
| seen.add(key) |
| queries.append(clean) |
| if not queries and query.strip(): |
| queries.append(" ".join(query.split())) |
| return queries[:MAX_SEARCH_KEYWORDS] |
|
|
|
|
| def _extract_search_query(question: str) -> str: |
| terms = _search_terms(question) |
| if not terms: |
| return question.strip() |
| return " ".join(terms[:8]) |
|
|
|
|
| def _year_value(item: PaperResult) -> int: |
| return int(item.year) if item.year.isdigit() else 0 |
|
|
|
|
| def _citation_value(item: PaperResult) -> int: |
| return int(item.citations) if item.citations.isdigit() else 0 |
|
|
|
|
| def _relevance_score(item: PaperResult, query: str) -> int: |
| terms = _search_terms(query) |
| if not terms: |
| return 0 |
| title = item.title.lower() |
| abstract = item.abstract.lower() |
| authors = item.authors.lower() |
| score = 0 |
| for term in terms: |
| if term in title: |
| score += 8 |
| if term in abstract: |
| score += 3 |
| if term in authors: |
| score += 1 |
| return score |
|
|
|
|
| def _rank_results(results: list[PaperResult], query: str) -> list[PaperResult]: |
| return sorted( |
| results, |
| key=lambda item: ( |
| _relevance_score(item, query), |
| _year_value(item), |
| min(_citation_value(item), 5000), |
| ), |
| reverse=True, |
| ) |
|
|
|
|
| def _arxiv_search_query(query: str) -> str: |
| terms = _search_terms(query)[:6] |
| if not terms: |
| return f"all:{query}" |
| return " OR ".join(f"ti:{term} OR abs:{term}" for term in terms) |
|
|
|
|
| def _pubmed_search_query(query: str) -> str: |
| terms = _search_terms(query)[:8] |
| if not terms: |
| return query |
| return " OR ".join(f'{term}[Title/Abstract]' for term in terms) |
|
|
|
|
| def _result_quality(result: PaperResult) -> tuple[int, int, int]: |
| return ( |
| 1 if result.abstract.strip() else 0, |
| _citation_value(result), |
| _year_value(result), |
| ) |
|
|
|
|
| def _titles_match(left: str, right: str) -> bool: |
| left_key = re.sub(r"[^a-z0-9]+", "", left.lower()) |
| right_key = re.sub(r"[^a-z0-9]+", "", right.lower()) |
| if not left_key or not right_key: |
| return False |
| if left_key == right_key: |
| return True |
| return SequenceMatcher(None, left_key, right_key).ratio() >= FUZZY_TITLE_THRESHOLD |
|
|
|
|
| def _render_result_insights(results: list[PaperResult]) -> str: |
| if not results: |
| return "" |
|
|
| source_counts = Counter(result.source for result in results) |
| years = [_year_value(result) for result in results if _year_value(result)] |
| year_range = f"{min(years)}-{max(years)}" if years else "Unknown" |
| abstracts = sum(1 for result in results if result.abstract.strip()) |
| top_source, top_source_count = source_counts.most_common(1)[0] |
| cards = [ |
| ("Papers", f"{len(results)}", "Deduplicated results"), |
| ("Sources", f"{len(source_counts)}", ", ".join(sorted(source_counts))), |
| ("Year Range", year_range, "Publication years"), |
| ("Abstracts", f"{abstracts}", "Ready for AI grounding"), |
| ("Top Source", top_source, f"{top_source_count} papers"), |
| ] |
| rendered_cards = "".join( |
| f'<div class="insight-card"><span>{html.escape(label)}</span>' |
| f"<strong>{html.escape(value)}</strong><small>{html.escape(detail)}</small></div>" |
| for label, value, detail in cards |
| ) |
| return f'<div class="insight-grid">{rendered_cards}</div>' |
|
|
|
|
| def _render_rubric_proof() -> str: |
| cards = "".join( |
| '<div class="proof-card">' |
| f"<strong>{html.escape(title)}</strong>" |
| f"<span>{html.escape(description)}</span>" |
| "</div>" |
| for title, description in RUBRIC_PROOF_POINTS |
| ) |
| return ( |
| '<section class="proof-section">' |
| "<h3>Backyard AI proof points</h3>" |
| f'<div class="proof-grid">{cards}</div>' |
| "</section>" |
| ) |
|
|
|
|
| def _abstract_snippet(text: str, max_chars: int = 220) -> str: |
| snippet = " ".join((text or "").split()) |
| if len(snippet) > max_chars: |
| snippet = snippet[:max_chars].rsplit(" ", 1)[0].rstrip() + "..." |
| return snippet |
|
|
|
|
| def _ensure_export_dir() -> Path: |
| EXPORT_DIR.mkdir(parents=True, exist_ok=True) |
| return EXPORT_DIR |
|
|
|
|
| def export_results_csv(results: list[PaperResult]) -> str | None: |
| if not results: |
| return None |
| path = _ensure_export_dir() / "scholar_lens_results.csv" |
| with path.open("w", newline="", encoding="utf-8") as handle: |
| writer = csv.writer(handle) |
| writer.writerow(["title", "year", "source", "authors", "citations", "url", "doi", "abstract"]) |
| for result in results: |
| writer.writerow( |
| [ |
| result.title, |
| result.year, |
| result.source, |
| result.authors, |
| result.citations, |
| result.url, |
| result.doi, |
| result.abstract, |
| ] |
| ) |
| return str(path) |
|
|
|
|
| def _bibtex_key(paper: PaperResult, index: int) -> str: |
| first_author = re.split(r"[,;]", paper.authors)[0].strip().split() |
| surname = first_author[-1] if first_author else "ref" |
| surname = re.sub(r"[^A-Za-z]", "", surname).lower() or "ref" |
| year = paper.year if paper.year.isdigit() else "nd" |
| return f"{surname}{year}_{index}" |
|
|
|
|
| def _to_bibtex(papers: list[PaperResult]) -> str: |
| entries = [] |
| for index, paper in enumerate(papers, start=1): |
| fields = [f" title = {{{paper.title}}}"] |
| if paper.authors and paper.authors != "Unknown": |
| fields.append(f" author = {{{paper.authors}}}") |
| if paper.year.isdigit(): |
| fields.append(f" year = {{{paper.year}}}") |
| if paper.doi: |
| fields.append(f" doi = {{{paper.doi}}}") |
| if paper.url and paper.url != "#": |
| fields.append(f" url = {{{paper.url}}}") |
| entries.append("@article{" + _bibtex_key(paper, index) + ",\n" + ",\n".join(fields) + "\n}") |
| return "\n\n".join(entries) |
|
|
|
|
| def export_bibtex(results: list[PaperResult]) -> str | None: |
| if not results: |
| return None |
| path = _ensure_export_dir() / "scholar_lens_references.bib" |
| path.write_text(_to_bibtex(results), encoding="utf-8") |
| return str(path) |
|
|
|
|
| def _paper_to_record(paper: PaperResult) -> dict[str, str]: |
| return { |
| "title": paper.title, |
| "year": paper.year, |
| "source": paper.source, |
| "authors": paper.authors, |
| "first_author": get_first_author(paper.authors), |
| "citations": paper.citations, |
| "url": paper.url, |
| "doi": paper.doi, |
| "abstract": paper.abstract, |
| } |
|
|
|
|
| def _keyword_tokens(*texts: str, limit: int = 10) -> list[str]: |
| counts: Counter[str] = Counter() |
| for text in texts: |
| for token in re.findall(r"[a-zA-Z][a-zA-Z0-9-]{2,}", (text or "").lower()): |
| token = token.strip("-") |
| if token and token not in GRAPH_STOPWORDS: |
| counts[token] += 1 |
| return [token for token, _ in counts.most_common(limit)] |
|
|
|
|
| def _community_label(keywords: list[str]) -> str: |
| if not keywords: |
| return "Unlabeled Topic" |
| cleaned = [keyword.replace("-", " ").title() for keyword in keywords[:2]] |
| return " / ".join(cleaned) |
|
|
|
|
| def _openalex_topic_labels(work: dict[str, Any]) -> list[str]: |
| labels: list[str] = [] |
| primary_topic = work.get("primary_topic") or {} |
| if primary_topic.get("display_name"): |
| labels.append(primary_topic["display_name"]) |
| for field_name in ("topics", "keywords"): |
| for item in work.get(field_name, []) or []: |
| label = item.get("display_name") |
| if label and label not in labels: |
| labels.append(label) |
| return labels[:8] |
|
|
|
|
| def _topic_similarity_edges(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]: |
| buckets: dict[str, list[str]] = {} |
| for node in nodes: |
| for label in node.get("topic_labels", [])[:5]: |
| buckets.setdefault(label, []).append(node["id"]) |
|
|
| edges: dict[tuple[str, str], dict[str, Any]] = {} |
| for label, ids in buckets.items(): |
| if len(ids) < 2 or len(ids) > 50: |
| continue |
| for source, target in itertools.combinations(sorted(ids), 2): |
| key = (source, target) |
| if key not in edges: |
| edges[key] = { |
| "source": source, |
| "target": target, |
| "weight": 0, |
| "type": "topic_similarity", |
| "label": label, |
| } |
| edges[key]["weight"] += 1 |
| return _top_weighted_edges(edges) |
|
|
|
|
| def _assign_graph_communities(nodes: list[dict[str, Any]]) -> None: |
| label_to_id: dict[str, int] = {} |
| for node in nodes: |
| label = _safe_text(node.get("topic_label"), "") |
| if not label: |
| label = _community_label(node.get("keywords", [])) |
| if label not in label_to_id: |
| label_to_id[label] = len(label_to_id) |
| community = label_to_id[label] |
| node["community"] = community |
| node["community_label"] = label |
| node["color"] = JEWEL_COLORS[community % len(JEWEL_COLORS)] |
|
|
|
|
| def _top_weighted_edges(edges: dict[tuple[str, str], dict[str, Any]]) -> list[dict[str, Any]]: |
| ranked = sorted( |
| edges.values(), |
| key=lambda edge: ( |
| EDGE_TYPE_PRIORITY.get(edge["type"], 9), |
| -edge["weight"], |
| ), |
| ) |
| return ranked[:CONSTELLATION_MAX_EDGES] |
|
|
|
|
| def _keyword_edges(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]: |
| buckets: dict[str, list[str]] = {} |
| for node in nodes: |
| for keyword in node.get("keywords", [])[:8]: |
| buckets.setdefault(keyword, []).append(node["id"]) |
|
|
| edges: dict[tuple[str, str], dict[str, Any]] = {} |
| for keyword, ids in buckets.items(): |
| if len(ids) < 2 or len(ids) > 45: |
| continue |
| for source, target in itertools.combinations(sorted(ids), 2): |
| key = (source, target) |
| if key not in edges: |
| edges[key] = { |
| "source": source, |
| "target": target, |
| "weight": 0, |
| "type": "keyword_cooccurrence", |
| "label": keyword, |
| } |
| edges[key]["weight"] += 1 |
| return _top_weighted_edges(edges) |
|
|
|
|
| def _constellation_payload( |
| *, |
| query: str, |
| nodes: list[dict[str, Any]], |
| edges: list[dict[str, Any]], |
| source: str, |
| direct_edges: int, |
| bibliographic_edges: int, |
| co_citation_edges: int, |
| topic_similarity_edges: int, |
| fallback_used: bool, |
| warnings: list[str] | None = None, |
| ) -> dict[str, Any]: |
| _assign_graph_communities(nodes) |
| degrees = Counter() |
| for edge in edges: |
| degrees[edge["source"]] += 1 |
| degrees[edge["target"]] += 1 |
| for node in nodes: |
| node["degree"] = degrees[node["id"]] |
|
|
| community_labels_by_id: dict[int, str] = {} |
| community_counts: Counter[int] = Counter() |
| for node in nodes: |
| community_id = int(node["community"]) |
| community_labels_by_id[community_id] = node["community_label"] |
| community_counts[community_id] += 1 |
| return { |
| "query": query, |
| "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), |
| "source": source, |
| "nodes": nodes, |
| "edges": edges, |
| "communities": [ |
| { |
| "id": community_id, |
| "label": community_labels_by_id[community_id], |
| "count": count, |
| "color": JEWEL_COLORS[community_id % len(JEWEL_COLORS)], |
| } |
| for community_id, count in community_counts.most_common() |
| ], |
| "data_completeness": { |
| "paper_count": len(nodes), |
| "edge_count": len(edges), |
| "direct_citation_edges": direct_edges, |
| "bibliographic_coupling_edges": bibliographic_edges, |
| "co_citation_edges": co_citation_edges, |
| "topic_similarity_edges": topic_similarity_edges, |
| "keyword_fallback_used": fallback_used, |
| "edge_policy": ( |
| "OpenAlex citation, shared-reference, and co-citation relationships" |
| if not fallback_used |
| else "Topic/keyword similarity fallback because retrieved citation links were sparse" |
| ), |
| "max_edges": CONSTELLATION_MAX_EDGES, |
| "warnings": warnings or [], |
| }, |
| } |
|
|
|
|
| def build_constellation_from_papers( |
| query: str, |
| papers: list[PaperResult], |
| ) -> dict[str, Any]: |
| nodes = [] |
| for index, paper in enumerate(papers): |
| nodes.append( |
| { |
| "id": f"paper-{index}", |
| "title": paper.title, |
| "year": paper.year, |
| "authors": paper.authors, |
| "first_author": get_first_author(paper.authors), |
| "citations": paper.citations, |
| "url": paper.url, |
| "doi": paper.doi, |
| "abstract": paper.abstract, |
| "topic_label": "", |
| "topic_labels": [], |
| "keywords": _keyword_tokens(paper.title, paper.abstract, limit=10), |
| } |
| ) |
| edges = _keyword_edges(nodes) |
| return _constellation_payload( |
| query=query, |
| nodes=nodes, |
| edges=edges, |
| source="Current search results", |
| direct_edges=0, |
| bibliographic_edges=0, |
| co_citation_edges=0, |
| topic_similarity_edges=0, |
| fallback_used=True, |
| warnings=["Search-result records do not include reference lists, so keyword similarity is used."], |
| ) |
|
|
|
|
| def fetch_openalex_constellation( |
| query: str, |
| limit: int = CONSTELLATION_OPENALEX_LIMIT, |
| ) -> tuple[dict[str, Any] | None, str | None]: |
| clean_query = (query or "").strip() |
| if not clean_query: |
| return None, "Enter a topic for the constellation." |
|
|
| params = { |
| "search": clean_query, |
| "per_page": min(max(limit, 25), 200), |
| "filter": "has_abstract:true", |
| "mailto": CONTACT_EMAIL, |
| "select": ( |
| "id,doi,title,publication_year,authorships,cited_by_count," |
| "referenced_works,abstract_inverted_index,primary_location,primary_topic,topics,keywords" |
| ), |
| } |
| try: |
| payload = _request_json("https://api.openalex.org/works", _openalex_params(params)) |
| except requests.RequestException: |
| return None, "OpenAlex is unavailable right now, so the constellation could not be built." |
|
|
| works = payload.get("results", []) |
| nodes: list[dict[str, Any]] = [] |
| references_by_id: dict[str, set[str]] = {} |
| for work in works: |
| work_id = _safe_text(work.get("id"), "") |
| if not work_id: |
| continue |
| authors = [ |
| authorship.get("author", {}).get("display_name", "") |
| for authorship in work.get("authorships", []) |
| ] |
| abstract = _reconstruct_abstract(work.get("abstract_inverted_index")) |
| topic_labels = _openalex_topic_labels(work) |
| location = work.get("primary_location") or {} |
| landing_page = _safe_text( |
| work.get("doi") |
| or location.get("landing_page_url") |
| or location.get("pdf_url") |
| or work_id, |
| "#", |
| ) |
| references = { |
| reference |
| for reference in work.get("referenced_works", []) |
| if isinstance(reference, str) |
| } |
| references_by_id[work_id] = references |
| nodes.append( |
| { |
| "id": work_id, |
| "title": _safe_text(work.get("title"), "Untitled paper"), |
| "year": _safe_text(work.get("publication_year")), |
| "authors": _shorten_authors(authors), |
| "first_author": get_first_author(authors), |
| "citations": _safe_text(work.get("cited_by_count"), "0"), |
| "url": landing_page, |
| "doi": _normalize_doi(work.get("doi", "")), |
| "abstract": abstract, |
| "topic_label": topic_labels[0] if topic_labels else "", |
| "topic_labels": topic_labels, |
| "keywords": _keyword_tokens(" ".join(topic_labels), work.get("title", ""), abstract, limit=12), |
| } |
| ) |
|
|
| id_set = {node["id"] for node in nodes} |
| edge_map: dict[tuple[str, str], dict[str, Any]] = {} |
| direct_edges = 0 |
| for source, references in references_by_id.items(): |
| for target in references & id_set: |
| if source == target: |
| continue |
| key = tuple(sorted((source, target))) |
| edge_map[key] = { |
| "source": source, |
| "target": target, |
| "weight": 3, |
| "type": "direct_citation", |
| "label": "direct citation", |
| } |
| direct_edges += 1 |
|
|
| bibliographic_edges = 0 |
| shared_reference_pairs: Counter[tuple[str, str]] = Counter() |
| reference_buckets: dict[str, list[str]] = {} |
| for work_id, references in references_by_id.items(): |
| for reference in references: |
| reference_buckets.setdefault(reference, []).append(work_id) |
| for cited_work, citing_ids in reference_buckets.items(): |
| if len(citing_ids) < 2 or len(citing_ids) > 60: |
| continue |
| for source, target in itertools.combinations(sorted(citing_ids), 2): |
| shared_reference_pairs[(source, target)] += 1 |
| for (source, target), weight in shared_reference_pairs.items(): |
| key = tuple(sorted((source, target))) |
| if key in edge_map or weight < 2: |
| continue |
| edge_map[key] = { |
| "source": source, |
| "target": target, |
| "weight": weight, |
| "type": "bibliographic_coupling", |
| "label": f"{weight} shared references", |
| } |
| bibliographic_edges += 1 |
|
|
| co_cited_edges = 0 |
| co_citation_pairs: Counter[tuple[str, str]] = Counter() |
| for references in references_by_id.values(): |
| internal_refs = sorted(references & id_set) |
| for left, right in itertools.combinations(internal_refs, 2): |
| co_citation_pairs[(left, right)] += 1 |
| for (source, target), weight in co_citation_pairs.items(): |
| key = tuple(sorted((source, target))) |
| if key in edge_map: |
| continue |
| edge_map[key] = { |
| "source": source, |
| "target": target, |
| "weight": weight, |
| "type": "co_citation", |
| "label": f"co-cited {weight}x", |
| } |
| co_cited_edges += 1 |
|
|
| evidence_edges = direct_edges + bibliographic_edges + co_cited_edges |
| fallback_used = evidence_edges < max(8, len(nodes) // 18) |
| if fallback_used: |
| edges = _topic_similarity_edges(nodes) or _keyword_edges(nodes) |
| else: |
| edges = _top_weighted_edges(edge_map) |
|
|
| return ( |
| _constellation_payload( |
| query=clean_query, |
| nodes=nodes, |
| edges=edges, |
| source="OpenAlex", |
| direct_edges=direct_edges, |
| bibliographic_edges=bibliographic_edges, |
| co_citation_edges=co_cited_edges, |
| topic_similarity_edges=len(edges) if fallback_used else 0, |
| fallback_used=fallback_used, |
| warnings=[] if not fallback_used else ["Citation links among retrieved papers were sparse."], |
| ), |
| None, |
| ) |
|
|
|
|
| def export_corpus_zip(graph: dict[str, Any] | None) -> str | None: |
| if not graph or not graph.get("nodes"): |
| return None |
| path = _ensure_export_dir() / "scholar_lens_constellation_corpus.zip" |
| records = [ |
| { |
| key: node.get(key, "") |
| for key in ( |
| "id", |
| "title", |
| "year", |
| "authors", |
| "citations", |
| "url", |
| "doi", |
| "abstract", |
| "topic_label", |
| "topic_labels", |
| "keywords", |
| ) |
| } |
| for node in graph.get("nodes", []) |
| ] |
| with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive: |
| archive.writestr("graph.json", json.dumps(graph, ensure_ascii=False, indent=2)) |
| archive.writestr("corpus.json", json.dumps(records, ensure_ascii=False, indent=2)) |
| archive.writestr("data-completeness.json", json.dumps(graph.get("data_completeness", {}), indent=2)) |
| archive.writestr( |
| "README.txt", |
| "Scholar Lens citation constellation corpus.\n" |
| "Direct/co-citation edges are derived from OpenAlex referenced_works when available.\n" |
| "If keyword_fallback_used is true, edges are keyword co-occurrence and not citation claims.\n", |
| ) |
| return str(path) |
|
|
|
|
| def export_summary_markdown(source_text: str, results_text: str, summary: str) -> str | None: |
| if not source_text.strip() and not results_text.strip() and not summary.strip(): |
| return None |
| path = _ensure_export_dir() / "scholar_lens_summary.md" |
| path.write_text( |
| "# Scholar Lens Summary\n\n" |
| "## Source Context\n\n" |
| f"{source_text.strip() or '_No source context provided._'}\n\n" |
| "## Results / Findings\n\n" |
| f"{results_text.strip() or '_No results/findings section provided._'}\n\n" |
| "## AI Summary\n\n" |
| f"{summary.strip() or '_No summary generated yet._'}\n", |
| encoding="utf-8", |
| ) |
| return str(path) |
|
|
|
|
| def _combine_paper_context(source_text: str, results_text: str = "") -> str: |
| parts = [] |
| if source_text and source_text.strip(): |
| parts.append(source_text.strip()) |
| if results_text and results_text.strip(): |
| parts.append(f"Results / Findings:\n{results_text.strip()}") |
| return "\n\n".join(parts) |
|
|
|
|
| def summarize_paper_context(source_text: str, results_text: str) -> str: |
| return summarize_with_modal(_combine_paper_context(source_text, results_text)) |
|
|
|
|
| def chat_about_paper(source_text: str, results_text: str, question: str) -> str: |
| clean_question = (question or "").strip() |
| context = _combine_paper_context(source_text, results_text) |
| if not context.strip(): |
| return "Load a paper or paste paper text before asking a question." |
| if not clean_question: |
| return "Ask a specific question about the paper." |
| limit_error = _text_limit_error( |
| clean_question, |
| "Paper question", |
| ASK_QUESTION_CHAR_LIMIT, |
| ASK_QUESTION_TOKEN_LIMIT, |
| ) |
| if limit_error: |
| return limit_error |
| context = _trim_to_budget( |
| context, |
| SYNTHESIS_CONTEXT_CHAR_LIMIT, |
| SYNTHESIS_CONTEXT_TOKEN_LIMIT, |
| ) |
| prompt_question = ( |
| f"{clean_question}\n\n" |
| "Answer using only this paper context. If the answer depends on the " |
| "results/findings section and it is not present, say that clearly." |
| ) |
| return synthesize_with_modal(prompt_question, f"[1] Paper context:\n{context}") |
|
|
|
|
| def ask_example(question: str) -> tuple[str, str, str]: |
| answer, references = ask_scholar_lens(question) |
| return question, answer, references |
|
|
|
|
| def _shorten_authors(authors: list[str], max_authors: int = 3) -> str: |
| clean_authors = [author.strip() for author in authors if author and author.strip()] |
| if not clean_authors: |
| return "Unknown" |
| if len(clean_authors) <= max_authors: |
| return ", ".join(clean_authors) |
| return f"{', '.join(clean_authors[:max_authors])}, et al." |
|
|
|
|
| def _extract_year(date_value: Any) -> str: |
| text = _safe_text(date_value, "") |
| match = re.search(r"\b(19|20)\d{2}\b", text) |
| return match.group(0) if match else "Unknown" |
|
|
|
|
| def _get_with_retries( |
| url: str, |
| params: dict[str, Any], |
| headers: dict[str, str] | None = None, |
| ) -> requests.Response: |
| request_headers = { |
| "User-Agent": "ScholarLens/1.0 (academic search app)", |
| **(headers or {}), |
| } |
| last_error: requests.RequestException | None = None |
| for attempt in range(REQUEST_RETRY_ATTEMPTS): |
| try: |
| response = requests.get( |
| url, |
| params=params, |
| timeout=REQUEST_TIMEOUT_SECONDS, |
| headers=request_headers, |
| ) |
| if response.status_code not in RETRY_STATUS_CODES: |
| response.raise_for_status() |
| return response |
| last_error = requests.HTTPError( |
| f"{response.status_code} response from {url}", |
| response=response, |
| ) |
| except (requests.Timeout, requests.ConnectionError, requests.HTTPError) as exc: |
| last_error = exc |
| response = getattr(exc, "response", None) |
| if response is not None and response.status_code not in RETRY_STATUS_CODES: |
| raise |
| if attempt < REQUEST_RETRY_ATTEMPTS - 1: |
| time.sleep(REQUEST_RETRY_BACKOFF_SECONDS * (attempt + 1)) |
| if last_error: |
| raise last_error |
| raise requests.RequestException(f"Request failed for {url}") |
|
|
|
|
| def _request_json(url: str, params: dict[str, Any]) -> dict[str, Any]: |
| response = _get_with_retries(url, params, headers={"Accept": "application/json"}) |
| response.raise_for_status() |
| return response.json() |
|
|
|
|
| def _openalex_params(params: dict[str, Any]) -> dict[str, Any]: |
| request_params = dict(params) |
| if OPENALEX_API_KEY: |
| request_params["api_key"] = OPENALEX_API_KEY |
| return request_params |
|
|
|
|
| def _reconstruct_abstract(inverted_index: dict[str, list[int]] | None) -> str: |
| """Rebuild plain text from OpenAlex's abstract inverted index. |
| |
| OpenAlex never returns a plain ``abstract`` field; it returns a |
| ``{word: [positions]}`` map. We sort words back into reading order. |
| """ |
| if not inverted_index: |
| return "" |
| positioned: list[tuple[int, str]] = [] |
| for word, positions in inverted_index.items(): |
| for position in positions: |
| positioned.append((position, word)) |
| positioned.sort(key=lambda pair: pair[0]) |
| return " ".join(word for _, word in positioned) |
|
|
|
|
| def _strip_markup(text: str) -> str: |
| """Crossref abstracts are JATS XML; reduce them to plain text.""" |
| return " ".join(re.sub(r"<[^>]+>", " ", text or "").split()) |
|
|
|
|
| def _normalize_doi(value: str) -> str: |
| doi = (value or "").lower().strip() |
| return re.sub(r"^https?://(dx\.)?doi\.org/", "", doi) |
|
|
|
|
| def search_semantic_scholar(query: str) -> tuple[list[PaperResult], str | None]: |
| |
| if not SEMANTIC_SCHOLAR_API_KEY: |
| return [], None |
| url = "https://api.semanticscholar.org/graph/v1/paper/search" |
| params = { |
| "query": query, |
| "limit": SEARCH_LIMIT_PER_SOURCE, |
| "fields": "title,year,authors,citationCount,url,abstract,externalIds", |
| } |
| headers = {"Accept": "application/json", "x-api-key": SEMANTIC_SCHOLAR_API_KEY} |
| try: |
| response = _get_with_retries(url, params, headers=headers) |
| payload = response.json() |
| except (requests.RequestException, ValueError): |
| return [], "Semantic Scholar is unavailable right now." |
|
|
| results: list[PaperResult] = [] |
| for paper in payload.get("data") or []: |
| authors = [author.get("name", "") for author in paper.get("authors", [])] |
| external_ids = paper.get("externalIds") or {} |
| results.append( |
| PaperResult( |
| title=_safe_text(paper.get("title"), "Untitled paper"), |
| year=_safe_text(paper.get("year")), |
| source="Semantic Scholar", |
| authors=_shorten_authors(authors), |
| citations=_safe_text(paper.get("citationCount"), "0"), |
| url=_safe_text(paper.get("url"), "#"), |
| abstract=_safe_text(paper.get("abstract"), ""), |
| doi=_normalize_doi(external_ids.get("DOI", "")), |
| ) |
| ) |
| return results, None |
|
|
|
|
| def search_openalex(query: str) -> tuple[list[PaperResult], str | None]: |
| url = "https://api.openalex.org/works" |
| params = { |
| "search": query, |
| "per_page": SEARCH_LIMIT_PER_SOURCE, |
| "filter": "has_abstract:true", |
| "mailto": CONTACT_EMAIL, |
| } |
| try: |
| payload = _request_json(url, params) |
| except requests.RequestException: |
| return [], "OpenAlex is unavailable right now." |
|
|
| results: list[PaperResult] = [] |
| for work in payload.get("results", []): |
| authors = [ |
| authorship.get("author", {}).get("display_name", "") |
| for authorship in work.get("authorships", []) |
| ] |
| results.append( |
| PaperResult( |
| title=_safe_text(work.get("title"), "Untitled paper"), |
| year=_safe_text(work.get("publication_year")), |
| source="OpenAlex", |
| authors=_shorten_authors(authors), |
| citations=_safe_text(work.get("cited_by_count"), "0"), |
| url=_safe_text(work.get("doi") or work.get("id"), "#"), |
| abstract=_reconstruct_abstract(work.get("abstract_inverted_index")), |
| doi=_normalize_doi(work.get("doi", "")), |
| ) |
| ) |
| return results, None |
|
|
|
|
| def _crossref_year(item: dict[str, Any]) -> str: |
| for key in ("published", "published-print", "published-online", "issued"): |
| parts = item.get(key, {}).get("date-parts", []) |
| if parts and parts[0] and parts[0][0]: |
| return str(parts[0][0]) |
| return "Unknown" |
|
|
|
|
| def search_crossref(query: str) -> tuple[list[PaperResult], str | None]: |
| url = "https://api.crossref.org/works" |
| params = { |
| "query": query, |
| "rows": SEARCH_LIMIT_PER_SOURCE, |
| "filter": "has-abstract:true", |
| "mailto": CONTACT_EMAIL, |
| } |
| try: |
| payload = _request_json(url, params) |
| except requests.RequestException: |
| return [], "Crossref is unavailable right now." |
|
|
| results: list[PaperResult] = [] |
| for item in payload.get("message", {}).get("items", []): |
| title_list = item.get("title") or ["Untitled paper"] |
| authors = [ |
| f"{author.get('given', '')} {author.get('family', '')}".strip() |
| for author in item.get("author", []) |
| ] |
| results.append( |
| PaperResult( |
| title=_safe_text(title_list[0], "Untitled paper"), |
| year=_crossref_year(item), |
| source="Crossref", |
| authors=_shorten_authors(authors), |
| citations=_safe_text(item.get("is-referenced-by-count"), "0"), |
| url=_safe_text(item.get("URL"), "#"), |
| abstract=_strip_markup(item.get("abstract", "")), |
| doi=_normalize_doi(item.get("DOI", "")), |
| ) |
| ) |
| return results, None |
|
|
|
|
| def search_arxiv(query: str) -> tuple[list[PaperResult], str | None]: |
| url = "https://export.arxiv.org/api/query" |
| params = { |
| "search_query": _arxiv_search_query(query), |
| "start": 0, |
| "max_results": SEARCH_LIMIT_PER_SOURCE, |
| "sortBy": "relevance", |
| "sortOrder": "descending", |
| } |
| try: |
| response = _get_with_retries(url, params) |
| response.raise_for_status() |
| root = ET.fromstring(response.text) |
| except (requests.RequestException, ET.ParseError): |
| return [], "arXiv is unavailable right now." |
|
|
| namespace = {"atom": "http://www.w3.org/2005/Atom"} |
| results: list[PaperResult] = [] |
| for entry in root.findall("atom:entry", namespace): |
| title = " ".join((entry.findtext("atom:title", default="", namespaces=namespace)).split()) |
| published = entry.findtext("atom:published", default="", namespaces=namespace) |
| link = entry.findtext("atom:id", default="#", namespaces=namespace) |
| abstract = " ".join((entry.findtext("atom:summary", default="", namespaces=namespace)).split()) |
| authors = [ |
| author.findtext("atom:name", default="", namespaces=namespace) |
| for author in entry.findall("atom:author", namespace) |
| ] |
| results.append( |
| PaperResult( |
| title=_safe_text(title, "Untitled paper"), |
| year=_extract_year(published), |
| source="arXiv", |
| authors=_shorten_authors(authors), |
| citations="N/A", |
| url=_safe_text(link, "#"), |
| abstract=_safe_text(abstract, ""), |
| ) |
| ) |
| return results, None |
|
|
|
|
| def search_pubmed(query: str) -> tuple[list[PaperResult], str | None]: |
| search_url = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi" |
| summary_url = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi" |
| search_params = { |
| "db": "pubmed", |
| "term": _pubmed_search_query(query), |
| "retmode": "json", |
| "retmax": SEARCH_LIMIT_PER_SOURCE, |
| "sort": "relevance", |
| } |
| try: |
| search_payload = _request_json(search_url, search_params) |
| paper_ids = search_payload.get("esearchresult", {}).get("idlist", []) |
| if not paper_ids: |
| return [], None |
| summary_payload = _request_json( |
| summary_url, |
| {"db": "pubmed", "id": ",".join(paper_ids), "retmode": "json"}, |
| ) |
| abstracts_by_id = _fetch_pubmed_abstracts(paper_ids) |
| except (requests.RequestException, ET.ParseError): |
| return [], "PubMed is unavailable right now." |
|
|
| summaries = summary_payload.get("result", {}) |
| results: list[PaperResult] = [] |
| for paper_id in paper_ids: |
| item = summaries.get(paper_id, {}) |
| authors = [author.get("name", "") for author in item.get("authors", [])] |
| results.append( |
| PaperResult( |
| title=_safe_text(item.get("title"), "Untitled paper"), |
| year=_extract_year(item.get("pubdate")), |
| source="PubMed", |
| authors=_shorten_authors(authors), |
| citations="N/A", |
| url=f"https://pubmed.ncbi.nlm.nih.gov/{paper_id}/", |
| abstract=abstracts_by_id.get(paper_id, ""), |
| ) |
| ) |
| return results, None |
|
|
|
|
| def _fetch_pubmed_abstracts(paper_ids: list[str]) -> dict[str, str]: |
| if not paper_ids: |
| return {} |
| fetch_url = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi" |
| response = _get_with_retries( |
| fetch_url, |
| params={ |
| "db": "pubmed", |
| "id": ",".join(paper_ids), |
| "retmode": "xml", |
| }, |
| ) |
| response.raise_for_status() |
| root = ET.fromstring(response.text) |
| abstracts: dict[str, str] = {} |
| for article in root.findall(".//PubmedArticle"): |
| paper_id = article.findtext(".//PMID", default="") |
| abstract_parts = [ |
| " ".join(part.itertext()).strip() |
| for part in article.findall(".//Abstract/AbstractText") |
| ] |
| abstract = " ".join(part for part in abstract_parts if part) |
| if paper_id and abstract: |
| abstracts[paper_id] = abstract |
| return abstracts |
|
|
|
|
| def _source_badge(source: str) -> str: |
| config = { |
| "Semantic Scholar": ("semantic", "🧠"), |
| "OpenAlex": ("openalex", "🌐"), |
| "Crossref": ("crossref", "🔗"), |
| "arXiv": ("arxiv", "📐"), |
| "PubMed": ("pubmed", "🧬"), |
| } |
| class_name, icon = config.get(source, ("default", "📄")) |
| return ( |
| f'<span class="source-badge {class_name}">' |
| f'<span class="badge-icon">{icon}</span>{html.escape(source)}' |
| f'</span>' |
| ) |
|
|
|
|
| def _render_results_table(results: list[PaperResult], start_index: int = 0) -> str: |
| if not results: |
| return """ |
| <div class="empty-state"> |
| <div class="empty-crest">📚</div> |
| <h3>Your library awaits</h3> |
| <p>Run a search to populate your scholarly results below.</p> |
| </div> |
| """ |
| rows = [] |
| for offset, result in enumerate(results): |
| result_index = start_index + offset |
| number = result_index + 1 |
| safe_url = html.escape(result.url, quote=True) |
| rows.append( |
| textwrap.dedent( |
| f""" |
| <tr |
| class="result-row" |
| > |
| <td class="num-cell">{number}</td> |
| <td class="title-cell">{html.escape(result.title)}</td> |
| <td class="year-cell">{html.escape(result.year)}</td> |
| <td>{_source_badge(result.source)}</td> |
| <td class="authors-cell">{html.escape(result.authors)}</td> |
| <td class="citation-cell">{html.escape(result.citations)}</td> |
| <td class="action-cell"><a class="paper-link" href="{safe_url}" target="_blank" rel="noopener noreferrer">Open ↗</a></td> |
| </tr> |
| """ |
| ).strip() |
| ) |
| return f""" |
| <div class="table-shell"> |
| <table class="results-table"> |
| <thead> |
| <tr> |
| <th style="width: 40px;">#</th> |
| <th>Title</th> |
| <th>Year</th> |
| <th>Source</th> |
| <th>Authors</th> |
| <th>Cites</th> |
| <th>Action</th> |
| </tr> |
| </thead> |
| <tbody>{''.join(rows)}</tbody> |
| </table> |
| </div> |
| """ |
|
|
|
|
| def _page_view(results: list[PaperResult], page: int) -> tuple[str, str, int]: |
| """Return (table_html, page_label, clamped_page) for one page of results.""" |
| total_pages = max(1, (len(results) + RESULTS_PER_PAGE - 1) // RESULTS_PER_PAGE) |
| page = max(0, min(page, total_pages - 1)) |
| start = page * RESULTS_PER_PAGE |
| chunk = results[start:start + RESULTS_PER_PAGE] |
| table = _render_results_table(chunk, start_index=start) |
| label = f"Page {page + 1} of {total_pages} · {len(results)} papers" if results else "" |
| return table, label, page |
|
|
|
|
| def _pagination_updates(results: list[PaperResult], page: int) -> tuple[gr.Button, gr.Button]: |
| total_pages = max(1, (len(results) + RESULTS_PER_PAGE - 1) // RESULTS_PER_PAGE) |
| has_results = bool(results) |
| return ( |
| gr.update(interactive=has_results and page > 0), |
| gr.update(interactive=has_results and page < total_pages - 1), |
| ) |
|
|
|
|
| def _dedupe_results(results: list[PaperResult]) -> list[PaperResult]: |
| """Drop duplicate papers that appear in more than one source. |
| |
| DOI wins first, then fuzzy title matching. When duplicates are found, keep |
| the version with the strongest metadata rather than whichever API returned |
| first. |
| """ |
| unique: list[PaperResult] = [] |
| for result in results: |
| doi = _normalize_doi(result.doi) |
| duplicate_index = None |
| for index, existing in enumerate(unique): |
| existing_doi = _normalize_doi(existing.doi) |
| if doi and existing_doi and doi == existing_doi: |
| duplicate_index = index |
| break |
| if _titles_match(result.title, existing.title): |
| duplicate_index = index |
| break |
|
|
| if duplicate_index is None: |
| unique.append(result) |
| continue |
|
|
| existing = unique[duplicate_index] |
| if _result_quality(result) > _result_quality(existing): |
| unique[duplicate_index] = result |
| return unique |
|
|
|
|
| def _collect_single_query_results(query: str) -> tuple[list[PaperResult], list[str]]: |
| """Query every source in parallel for one keyword phrase.""" |
| search_functions = [ |
| search_semantic_scholar, |
| search_openalex, |
| search_crossref, |
| search_arxiv, |
| search_pubmed, |
| ] |
| results: list[PaperResult] = [] |
| warnings: list[str] = [] |
|
|
| with ThreadPoolExecutor(max_workers=len(search_functions)) as executor: |
| future_to_search = {executor.submit(fn, query): fn.__name__ for fn in search_functions} |
| for future in as_completed(future_to_search): |
| try: |
| source_results, warning = future.result() |
| results.extend(source_results) |
| if warning: |
| warnings.append(warning) |
| except Exception as e: |
| warnings.append(f"An error occurred searching {future_to_search[future]}: {str(e)}") |
|
|
| results = _dedupe_results(results) |
|
|
| return _rank_results(results, query), warnings |
|
|
|
|
| def _collect_results(query: str) -> tuple[list[PaperResult], list[str]]: |
| """Query one or more keyword phrases, then de-duplicate and rank results.""" |
| queries = _split_search_queries(query) |
| if not queries: |
| return [], [] |
| if len(queries) == 1: |
| return _collect_single_query_results(queries[0]) |
|
|
| results: list[PaperResult] = [] |
| warnings: list[str] = [] |
| for search_query in queries: |
| source_results, source_warnings = _collect_single_query_results(search_query) |
| results.extend(source_results) |
| warnings.extend(source_warnings) |
|
|
| combined_query = " ".join(queries) |
| return _rank_results(_dedupe_results(results), combined_query), warnings |
|
|
|
|
| def _selector_choices(results: list[PaperResult]) -> list[str]: |
| return [f"{index + 1}. {result.title}" for index, result in enumerate(results)] |
|
|
|
|
| def _compare_selector_updates(results: list[PaperResult]) -> tuple[gr.Dropdown, gr.Dropdown]: |
| choices = _selector_choices(results) |
| return ( |
| gr.update(choices=choices, value=choices[0] if len(results) >= 1 else None), |
| gr.update(choices=choices, value=choices[1] if len(results) >= 2 else None), |
| ) |
|
|
|
|
| def search_all_sources(query: str): |
| clean_query = query.strip() |
| search_queries = _split_search_queries(clean_query) |
| if not clean_query: |
| table, label, page = _page_view([], 0) |
| prev_update, next_update = _pagination_updates([], page) |
| compare_left, compare_right = _compare_selector_updates([]) |
| return ( |
| "Enter one research topic, or multiple keyword phrases separated by commas or new lines.", |
| table, |
| "", |
| [], |
| gr.update(choices=[], value=None), |
| label, |
| page, |
| prev_update, |
| next_update, |
| None, |
| compare_left, |
| compare_right, |
| ) |
| if len(clean_query) > SEARCH_QUERY_CHAR_LIMIT: |
| table, label, page = _page_view([], 0) |
| prev_update, next_update = _pagination_updates([], page) |
| compare_left, compare_right = _compare_selector_updates([]) |
| return ( |
| f"Search topic is too long. Please keep it under {SEARCH_QUERY_CHAR_LIMIT} characters.", |
| table, |
| "", |
| [], |
| gr.update(choices=[], value=None), |
| label, |
| page, |
| prev_update, |
| next_update, |
| None, |
| compare_left, |
| compare_right, |
| ) |
|
|
| results, warnings = _collect_results(clean_query) |
| query_note = ( |
| f" across **{len(search_queries)}** keyword searches" |
| if len(search_queries) > 1 |
| else " across all sources" |
| ) |
|
|
| if warnings and results: |
| status = f"✓ Found **{len(results)}** papers. " + " ".join(warnings) |
| elif warnings: |
| status = " ".join(warnings) |
| else: |
| status = f"✓ Found **{len(results)}** papers across all sources." |
|
|
| if not results: |
| if warnings: |
| status = "No papers could be loaded because the source APIs are unavailable right now. " + " ".join(warnings) |
| else: |
| status = "No papers found. Try a broader research topic or a different phrase." |
|
|
| if results: |
| status = f"Found **{len(results)}** papers{query_note}." |
| if warnings: |
| status += " " + " ".join(warnings) |
|
|
| table, label, page = _page_view(results, 0) |
| prev_update, next_update = _pagination_updates(results, page) |
| results_csv = export_results_csv(results) if results else None |
| compare_left, compare_right = _compare_selector_updates(results) |
| return ( |
| status, |
| table, |
| _render_result_insights(results), |
| results, |
| gr.update(choices=_selector_choices(results), value=None), |
| label, |
| page, |
| prev_update, |
| next_update, |
| results_csv, |
| compare_left, |
| compare_right, |
| ) |
|
|
|
|
| def change_page(results: list[PaperResult], page: int, delta: int): |
| table, label, clamped_page = _page_view(results, (page or 0) + delta) |
| prev_update, next_update = _pagination_updates(results, clamped_page) |
| return table, label, clamped_page, prev_update, next_update |
|
|
|
|
| def _modal_headers() -> dict[str, str]: |
| return {"Authorization": f"Bearer {MODAL_API_TOKEN}"} |
|
|
|
|
| def _modal_config_error(endpoint_url: str) -> str | None: |
| if not endpoint_url: |
| return ( |
| "The AI endpoint is not configured. Set the Modal endpoint URL " |
| "environment variable before using this feature." |
| ) |
| if not MODAL_API_TOKEN: |
| return ( |
| "The AI endpoint token is not configured. Set " |
| "SCHOLAR_LENS_MODAL_TOKEN before using this feature." |
| ) |
| return None |
|
|
|
|
| def _modal_request_error_message(exc: requests.RequestException, label: str) -> str: |
| response = getattr(exc, "response", None) |
| if response is not None: |
| try: |
| payload = response.json() |
| except ValueError: |
| payload = {} |
| detail = payload.get("detail") or payload.get("error") |
| if detail: |
| return f"{label}: {detail}" |
| return f"{label} is unavailable right now. Please try again shortly." |
|
|
|
|
| def summarize_with_modal(text: str) -> str: |
| if not text or len(text.strip()) < 50: |
| return "Please provide a longer abstract or paper text to summarize." |
| clean_text = text.strip() |
| limit_error = _text_limit_error( |
| clean_text, |
| "Paper text", |
| SUMMARY_INPUT_CHAR_LIMIT, |
| SUMMARY_INPUT_TOKEN_LIMIT, |
| ) |
| if limit_error: |
| return limit_error |
| config_error = _modal_config_error(MODAL_SUMMARIZE_URL) |
| if config_error: |
| return config_error |
| try: |
| response = requests.post( |
| MODAL_SUMMARIZE_URL, |
| json={"text": clean_text}, |
| headers=_modal_headers(), |
| timeout=120, |
| ) |
| response.raise_for_status() |
| except requests.Timeout: |
| return ( |
| "The AI summarizer timed out (the model may be cold-starting). " |
| "Please try again in a few seconds." |
| ) |
| except requests.RequestException as exc: |
| return _modal_request_error_message(exc, "The AI summarizer") |
|
|
| try: |
| payload = response.json() |
| except ValueError: |
| return "The AI summarizer returned an unexpected response. Please try again shortly." |
|
|
| if isinstance(payload, dict): |
| return _safe_text(payload.get("summary") or payload.get("error"), "No summary returned.") |
| return _safe_text(payload, "No summary returned.") |
|
|
|
|
| def _papers_for_synthesis(results: list[PaperResult]) -> list[PaperResult]: |
| """Pick the top results that actually carry an abstract to ground on.""" |
| with_abstract = [paper for paper in results if paper.abstract.strip()] |
| return with_abstract[:SYNTHESIS_PAPER_COUNT] |
|
|
|
|
| def _build_synthesis_context(papers: list[PaperResult]) -> str: |
| blocks = [] |
| for index, paper in enumerate(papers, start=1): |
| abstract = _trim_to_budget( |
| paper.abstract, |
| MAX_ABSTRACT_CHARS, |
| MAX_ABSTRACT_TOKENS, |
| ) |
| blocks.append( |
| f"[{index}] Title: {paper.title}\n" |
| f"Source: {paper.source} ({paper.year})\n" |
| f"Abstract: {abstract}" |
| ) |
| context = "\n\n".join(blocks) |
| if ( |
| len(context) > SYNTHESIS_CONTEXT_CHAR_LIMIT |
| or _rough_token_count(context) > SYNTHESIS_CONTEXT_TOKEN_LIMIT |
| ): |
| blocks.pop() |
| break |
| return "\n\n".join(blocks) |
|
|
|
|
| def _render_references(papers: list[PaperResult]) -> str: |
| if not papers: |
| return "" |
| items = [] |
| for index, paper in enumerate(papers, start=1): |
| safe_url = html.escape(paper.url, quote=True) |
| snippet = _abstract_snippet(paper.abstract) |
| items.append( |
| f'<li class="ref-item">' |
| f'<span class="ref-num">[{index}]</span>' |
| f'<span class="ref-body">' |
| f'<a class="ref-link" href="{safe_url}" target="_blank" rel="noopener noreferrer">{html.escape(paper.title)}</a>' |
| f'<span class="ref-meta">{_source_badge(paper.source)} · {html.escape(paper.year)} · {html.escape(paper.authors)}</span>' |
| f'<span class="ref-snippet">{html.escape(snippet)}</span>' |
| f'</span></li>' |
| ) |
| return ( |
| '<div class="refs-shell"><h3>📚 Sources the answer is grounded in</h3>' |
| f'<ul class="refs-list">{"".join(items)}</ul></div>' |
| ) |
|
|
|
|
| def synthesize_with_modal(question: str, context: str) -> str: |
| question_limit_error = _text_limit_error( |
| question, |
| "Research question", |
| ASK_QUESTION_CHAR_LIMIT, |
| ASK_QUESTION_TOKEN_LIMIT, |
| ) |
| if question_limit_error: |
| return question_limit_error |
| context_limit_error = _text_limit_error( |
| context, |
| "Synthesis context", |
| SYNTHESIS_CONTEXT_CHAR_LIMIT, |
| SYNTHESIS_CONTEXT_TOKEN_LIMIT, |
| ) |
| if context_limit_error: |
| return context_limit_error |
| config_error = _modal_config_error(MODAL_SYNTHESIZE_URL) |
| if config_error: |
| return config_error |
| try: |
| response = requests.post( |
| MODAL_SYNTHESIZE_URL, |
| json={"question": question, "context": context}, |
| headers=_modal_headers(), |
| timeout=180, |
| ) |
| response.raise_for_status() |
| except requests.Timeout: |
| return ( |
| "The AI synthesizer timed out (the model may be cold-starting). " |
| "Please try again in a few seconds." |
| ) |
| except requests.RequestException as exc: |
| return _modal_request_error_message(exc, "The AI synthesizer") |
|
|
| try: |
| payload = response.json() |
| except ValueError: |
| return "The AI synthesizer returned an unexpected response. Please try again shortly." |
|
|
| if isinstance(payload, dict): |
| return _safe_text(payload.get("answer") or payload.get("error"), "No answer returned.") |
| return _safe_text(payload, "No answer returned.") |
|
|
|
|
| def ask_scholar_lens(question: str) -> tuple[str, str]: |
| """Search every source, then have the small model answer with citations.""" |
| clean_question = question.strip() |
| if not clean_question: |
| return "Enter a research question to begin.", "" |
| question_limit_error = _text_limit_error( |
| clean_question, |
| "Research question", |
| ASK_QUESTION_CHAR_LIMIT, |
| ASK_QUESTION_TOKEN_LIMIT, |
| ) |
| if question_limit_error: |
| return question_limit_error, "" |
|
|
| search_query = _extract_search_query(clean_question) |
| results, warnings = _collect_results(search_query) |
| if not results: |
| note = " ".join(warnings) if warnings else "" |
| return ( |
| f"No papers were found for that question. Try rephrasing it.\n\n{note}".strip(), |
| "", |
| ) |
|
|
| papers = _papers_for_synthesis(results) |
| if not papers: |
| return ( |
| "Papers were found, but none included an abstract to reason over. " |
| "Try a broader or differently worded question.", |
| "", |
| ) |
|
|
| context = _build_synthesis_context(papers) |
| if not context: |
| return ( |
| "The retrieved abstracts were too large to fit the demo context " |
| "budget. Try a narrower or more specific question.", |
| "", |
| ) |
| answer = synthesize_with_modal(clean_question, context) |
| return f"**Search terms used:** `{search_query}`\n\n{answer}", _render_references(papers) |
|
|
|
|
| def select_result(event: gr.SelectData) -> int | None: |
| if event.index is None: |
| return None |
| index = event.index |
| if isinstance(index, (list, tuple)): |
| if not index: |
| return None |
| index = index[0] |
| return int(index) |
|
|
|
|
| def load_selected_paper( |
| selected_index: int | None, |
| results: list[PaperResult], |
| ) -> tuple[str, str, str, gr.Tabs]: |
| if selected_index is None or selected_index >= len(results): |
| return ( |
| "", |
| "Select a paper from the results table first.", |
| "", |
| gr.update(selected="summarize"), |
| ) |
| paper = results[selected_index] |
| abstract = paper.abstract.strip() |
| if not abstract: |
| return ( |
| f"{paper.title}\n\nNo abstract is available for this result. Paste paper text here to summarize it.", |
| "This paper does not include an abstract from the source API.", |
| "", |
| gr.update(selected="summarize"), |
| ) |
| paper_text = f"Title: {paper.title}\n\nAbstract: {abstract}" |
| return paper_text, f"📖 Loaded: *{paper.title}*", "", gr.update(selected="summarize") |
|
|
|
|
| def summarize_selected_paper( |
| selected_index: Any, |
| results: list[PaperResult], |
| ) -> tuple[str, str, str, gr.Tabs]: |
| try: |
| index = int(selected_index) |
| except (TypeError, ValueError): |
| index = None |
| paper_text, load_status, _, tab_update = load_selected_paper(index, results) |
| if not paper_text: |
| return paper_text, load_status, "", tab_update |
| if "No abstract is available" in paper_text: |
| return ( |
| paper_text, |
| load_status, |
| "No abstract was available to summarize. Paste the paper text above and click Summarize with AI.", |
| tab_update, |
| ) |
| return paper_text, load_status, summarize_with_modal(paper_text), tab_update |
|
|
|
|
| def load_selected_paper_reset_chat( |
| selected_index: Any, |
| results: list[PaperResult], |
| ) -> tuple[str, str, str, gr.Tabs, str, str, str]: |
| try: |
| index = int(selected_index) |
| except (TypeError, ValueError): |
| index = None |
| paper_text, load_status, summary, tab_update = load_selected_paper(index, results) |
| if paper_text and not summary: |
| summary = "Paper loaded. Click Summarize with AI to generate the summary." |
| return paper_text, load_status, summary, tab_update, "", "", DEFAULT_PAPER_CHAT_ANSWER |
|
|
|
|
| def summarize_row_selection( |
| selected_index: Any, |
| results: list[PaperResult], |
| ) -> tuple[str, str, str, gr.Tabs, gr.Dropdown, str, str, str]: |
| paper_text, load_status, summary, tab_update, results_text, chat_question, chat_output = load_selected_paper_reset_chat( |
| selected_index, |
| results, |
| ) |
| try: |
| dropdown_value = int(selected_index) |
| except (TypeError, ValueError): |
| dropdown_value = None |
| return ( |
| paper_text, |
| load_status, |
| summary, |
| tab_update, |
| gr.update(value=dropdown_value), |
| results_text, |
| chat_question, |
| chat_output, |
| ) |
|
|
|
|
| def summarize_selected_paper_reset_chat( |
| selected_index: Any, |
| results: list[PaperResult], |
| ) -> tuple[str, str, str, gr.Tabs, str, str, str]: |
| paper_text, load_status, summary, tab_update = summarize_selected_paper( |
| selected_index, |
| results, |
| ) |
| return paper_text, load_status, summary, tab_update, "", "", DEFAULT_PAPER_CHAT_ANSWER |
|
|
|
|
| def _empty_constellation_html(message: str = "Build a constellation to explore connected papers.") -> str: |
| safe_message = html.escape(message) |
| return f""" |
| <div class="constellation-empty"> |
| <h3>Literature Constellation</h3> |
| <p>{safe_message}</p> |
| </div> |
| """ |
|
|
|
|
| def _render_constellation_html(graph: dict[str, Any] | None) -> str: |
| if not graph or not graph.get("nodes"): |
| return _empty_constellation_html() |
|
|
| frame_id = f"constellation-{uuid.uuid4().hex}" |
| graph_json = json.dumps(graph, ensure_ascii=False) |
| srcdoc = f""" |
| <!doctype html> |
| <html> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <style> |
| html, body {{ margin: 0; width: 100%; height: 100%; overflow: hidden; background: #02030a; color: #f8fafc; font-family: Inter, Arial, sans-serif; }} |
| #app {{ position: relative; width: 100vw; height: 100vh; overflow: hidden; background: radial-gradient(circle at 48% 42%, rgba(42, 75, 140, .22), transparent 34%), linear-gradient(135deg, #02030a 0%, #070816 58%, #02030a 100%); }} |
| canvas {{ position: absolute; inset: 0; width: 100%; height: 100%; cursor: grab; }} |
| canvas.dragging {{ cursor: grabbing; }} |
| .top {{ position: absolute; top: 22px; left: 28px; right: 28px; pointer-events: none; z-index: 4; display: flex; justify-content: space-between; gap: 24px; align-items: start; }} |
| .brand {{ color: rgba(255,255,255,.46); font: 800 10px/1.4 monospace; letter-spacing: 3px; text-transform: uppercase; }} |
| h1 {{ margin: 5px 0 6px; font: italic 700 clamp(28px, 4vw, 52px)/1 Georgia, serif; color: rgba(255,255,255,.94); text-shadow: 0 0 28px rgba(72,182,255,.22); }} |
| .meta {{ color: rgba(255,255,255,.58); font: 700 12px/1.45 monospace; letter-spacing: .7px; max-width: 680px; }} |
| .controls {{ pointer-events: auto; display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; max-width: 380px; }} |
| .chip {{ border: 1px solid rgba(255,255,255,.12); color: rgba(255,255,255,.74); background: rgba(5,7,18,.62); border-radius: 999px; padding: 8px 11px; font: 800 10px/1 monospace; letter-spacing: 1px; backdrop-filter: blur(12px); }} |
| .chip.active {{ color: #06111f; background: #48b6ff; border-color: rgba(72,182,255,.8); box-shadow: 0 0 22px rgba(72,182,255,.32); }} |
| .legend {{ position: absolute; right: 24px; bottom: 26px; width: min(330px, 30vw); max-height: 38vh; overflow: auto; padding: 14px 16px; background: rgba(5,7,18,.72); border: 1px solid rgba(255,255,255,.12); border-radius: 8px; backdrop-filter: blur(16px); z-index: 6; }} |
| .legend-title {{ color: rgba(255,255,255,.44); font: 800 10px/1.5 monospace; letter-spacing: 2px; margin-bottom: 8px; }} |
| .legend-row {{ display: grid; grid-template-columns: 13px 1fr auto; gap: 9px; align-items: center; padding: 6px 0; cursor: pointer; color: rgba(255,255,255,.72); font: 700 11px/1.25 monospace; }} |
| .dot {{ width: 10px; height: 10px; border-radius: 50%; box-shadow: 0 0 16px currentColor; }} |
| .legend-row:hover, .legend-row.active {{ color: #fff; }} |
| .detail {{ position: absolute; top: 132px; right: 24px; width: min(420px, 32vw); max-height: 54vh; overflow: auto; padding: 17px 19px; background: rgba(5,7,18,.88); border: 1px solid rgba(72,182,255,.28); border-radius: 8px; box-shadow: 0 0 34px rgba(72,182,255,.12); z-index: 7; display: none; }} |
| .detail h2 {{ margin: 0 0 8px; font: 700 18px/1.22 Georgia, serif; color: #fff; }} |
| .detail .byline {{ color: rgba(255,255,255,.58); font: 700 11px/1.55 monospace; margin-bottom: 12px; }} |
| .detail .relation {{ color: #66e28f; font: 800 10px/1.4 monospace; letter-spacing: 1px; text-transform: uppercase; margin-bottom: 8px; }} |
| .detail p {{ color: rgba(255,255,255,.78); font: 13px/1.55 Arial, sans-serif; }} |
| .detail a {{ display: inline-flex; margin-top: 10px; color: #48b6ff; text-decoration: none; font-weight: 800; }} |
| .evidence {{ position: absolute; left: 50%; bottom: 20px; transform: translateX(-50%); max-width: min(720px, 72vw); padding: 9px 13px; border-radius: 999px; background: rgba(5,7,18,.66); border: 1px solid rgba(255,255,255,.12); color: rgba(255,255,255,.62); font: 800 10px/1.35 monospace; letter-spacing: 1px; text-align: center; backdrop-filter: blur(14px); z-index: 5; pointer-events: none; }} |
| @media (max-width: 820px) {{ .top {{ left: 14px; right: 14px; display: block; }} .controls {{ margin-top: 8px; justify-content: flex-start; }} .legend {{ left: 14px; right: 14px; bottom: 14px; width: auto; max-height: 26vh; }} .detail {{ left: 14px; right: 14px; width: auto; top: 156px; max-height: 38vh; }} .evidence {{ display: none; }} }} |
| </style> |
| </head> |
| <body> |
| <div id="app"> |
| <canvas id="map"></canvas> |
| <div class="top"> |
| <div> |
| <div class="brand">CONNECTED LITERATURE MAP</div> |
| <h1>Literature Constellation</h1> |
| <div class="meta" id="meta"></div> |
| </div> |
| <div class="controls" id="controls"></div> |
| </div> |
| <div class="legend" id="legend"><div class="legend-title">CLUSTERS - HOVER TO ISOLATE</div></div> |
| <div class="detail" id="detail"></div> |
| <div class="evidence" id="evidence"></div> |
| </div> |
| <script> |
| const graph = {graph_json}; |
| function escapeHtml(value) {{ return String(value || '').replace(/[&<>"']/g, ch => ({{'&':'&','<':'<','>':'>','"':'"',"'":'''}}[ch])); }} |
| function escapeAttr(value) {{ return escapeHtml(value).replace(/`/g, '`'); }} |
| const canvas = document.getElementById('map'); |
| const ctx = canvas.getContext('2d'); |
| const nodeById = new Map(); |
| const neighbors = new Map(); |
| let activeCommunity = null; |
| let selectedId = null; |
| let hoverId = null; |
| let transform = {{ x: 0, y: 0, scale: 1 }}; |
| let dragStart = null; |
| const relationLabels = {{ |
| direct_citation: 'Citation', |
| bibliographic_coupling: 'Shared refs', |
| co_citation: 'Co-cited', |
| topic_similarity: 'Topic', |
| keyword_cooccurrence: 'Keyword' |
| }}; |
| const relationColors = {{ |
| direct_citation: '#ffffff', |
| bibliographic_coupling: '#48b6ff', |
| co_citation: '#66e28f', |
| topic_similarity: '#ffd166', |
| keyword_cooccurrence: '#9b8cff' |
| }}; |
| let activeRelations = new Set(Object.keys(relationLabels)); |
| graph.nodes.forEach((node, index) => {{ |
| nodeById.set(node.id, node); |
| neighbors.set(node.id, new Set()); |
| const communityIndex = Math.max(0, graph.communities.findIndex(comm => comm.id === node.community)); |
| const angle = (communityIndex / Math.max(1, graph.communities.length)) * Math.PI * 2; |
| const ring = 210 + communityIndex * 9; |
| const jitter = ((index * 73) % 29) - 14; |
| node.x = Math.cos(angle) * ring + Math.cos(index * 1.618) * jitter * 4; |
| node.y = Math.sin(angle) * ring + Math.sin(index * 1.618) * jitter * 4; |
| node.vx = 0; |
| node.vy = 0; |
| node.radius = 5 + Math.min(18, Math.sqrt(Math.max(0, Number(node.citations) || 0)) * .65) + Math.min(8, Math.sqrt((node.degree || 0) + 1)); |
| }}); |
| graph.edges.forEach(edge => {{ |
| if (neighbors.has(edge.source)) neighbors.get(edge.source).add(edge.target); |
| if (neighbors.has(edge.target)) neighbors.get(edge.target).add(edge.source); |
| }}); |
| function edgeDistance(type) {{ |
| return type === 'direct_citation' ? 82 : type === 'bibliographic_coupling' ? 112 : type === 'co_citation' ? 126 : type === 'topic_similarity' ? 148 : 162; |
| }} |
| function runLayout() {{ |
| const nodes = graph.nodes; |
| for (let step = 0; step < 260; step++) {{ |
| for (let i = 0; i < nodes.length; i++) {{ |
| const a = nodes[i]; |
| for (let j = i + 1; j < nodes.length; j++) {{ |
| const b = nodes[j]; |
| let dx = b.x - a.x; |
| let dy = b.y - a.y; |
| let dist2 = dx * dx + dy * dy + .01; |
| let force = 1550 / dist2; |
| let dist = Math.sqrt(dist2); |
| let fx = (dx / dist) * force; |
| let fy = (dy / dist) * force; |
| a.vx -= fx; a.vy -= fy; b.vx += fx; b.vy += fy; |
| }} |
| }} |
| graph.edges.forEach(edge => {{ |
| const a = nodeById.get(edge.source), b = nodeById.get(edge.target); |
| if (!a || !b) return; |
| let dx = b.x - a.x; |
| let dy = b.y - a.y; |
| let dist = Math.sqrt(dx * dx + dy * dy) || 1; |
| let wanted = edgeDistance(edge.type) - Math.min(36, Math.log1p(edge.weight || 1) * 9); |
| let force = (dist - wanted) * (.006 + Math.min(.018, (edge.weight || 1) * .0015)); |
| let fx = (dx / dist) * force; |
| let fy = (dy / dist) * force; |
| a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy; |
| }}); |
| nodes.forEach(node => {{ |
| const angle = (node.community / Math.max(1, graph.communities.length)) * Math.PI * 2; |
| const anchorX = Math.cos(angle) * 260; |
| const anchorY = Math.sin(angle) * 190; |
| node.vx += (anchorX - node.x) * .002; |
| node.vy += (anchorY - node.y) * .002; |
| node.x += node.vx; |
| node.y += node.vy; |
| node.vx *= .72; |
| node.vy *= .72; |
| }}); |
| }} |
| }} |
| runLayout(); |
| function resize() {{ |
| canvas.width = Math.floor(innerWidth * devicePixelRatio); |
| canvas.height = Math.floor(innerHeight * devicePixelRatio); |
| canvas.style.width = innerWidth + 'px'; |
| canvas.style.height = innerHeight + 'px'; |
| ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); |
| draw(); |
| }} |
| function toScreen(node) {{ return {{ x: innerWidth / 2 + (node.x + transform.x) * transform.scale, y: innerHeight / 2 + (node.y + transform.y) * transform.scale }}; }} |
| function fromScreen(x, y) {{ return {{ x: (x - innerWidth / 2) / transform.scale - transform.x, y: (y - innerHeight / 2) / transform.scale - transform.y }}; }} |
| function visibleNode(node) {{ |
| const selectedNeighbors = selectedId ? neighbors.get(selectedId) || new Set() : new Set(); |
| return (activeCommunity === null || node.community === activeCommunity) && (!selectedId || node.id === selectedId || selectedNeighbors.has(node.id)); |
| }} |
| function edgeVisible(edge) {{ |
| if (!activeRelations.has(edge.type)) return false; |
| const a = nodeById.get(edge.source), b = nodeById.get(edge.target); |
| return a && b && visibleNode(a) && visibleNode(b); |
| }} |
| function draw() {{ |
| ctx.clearRect(0, 0, innerWidth, innerHeight); |
| const grad = ctx.createRadialGradient(innerWidth * .46, innerHeight * .46, 20, innerWidth * .46, innerHeight * .46, Math.max(innerWidth, innerHeight) * .62); |
| grad.addColorStop(0, 'rgba(39,69,132,.28)'); |
| grad.addColorStop(1, 'rgba(2,3,10,0)'); |
| ctx.fillStyle = grad; |
| ctx.fillRect(0, 0, innerWidth, innerHeight); |
| graph.edges.forEach(edge => {{ |
| if (!edgeVisible(edge)) return; |
| const a = toScreen(nodeById.get(edge.source)); |
| const b = toScreen(nodeById.get(edge.target)); |
| const selected = selectedId && (edge.source === selectedId || edge.target === selectedId); |
| ctx.strokeStyle = relationColors[edge.type] || 'rgba(255,255,255,.35)'; |
| ctx.globalAlpha = selected ? .68 : edge.type === 'direct_citation' ? .34 : .16; |
| ctx.lineWidth = selected ? 2.4 : Math.max(.55, Math.min(2.4, Math.log1p(edge.weight || 1) * .46)); |
| ctx.beginPath(); |
| ctx.moveTo(a.x, a.y); |
| ctx.lineTo(b.x, b.y); |
| ctx.stroke(); |
| }}); |
| ctx.globalAlpha = 1; |
| graph.nodes.forEach(node => {{ |
| const p = toScreen(node); |
| const selected = node.id === selectedId; |
| const hovered = node.id === hoverId; |
| const neighbor = selectedId && (neighbors.get(selectedId) || new Set()).has(node.id); |
| const visible = visibleNode(node); |
| const r = (node.radius * (selected ? 1.45 : hovered ? 1.3 : neighbor ? 1.16 : 1)) * transform.scale; |
| ctx.globalAlpha = visible ? 1 : .14; |
| ctx.fillStyle = node.color || '#48b6ff'; |
| ctx.shadowColor = node.color || '#48b6ff'; |
| ctx.shadowBlur = visible ? (selected || hovered ? 24 : 13) : 0; |
| ctx.beginPath(); |
| ctx.arc(p.x, p.y, Math.max(3, r), 0, Math.PI * 2); |
| ctx.fill(); |
| ctx.shadowBlur = 0; |
| ctx.strokeStyle = selected || hovered ? '#fff' : 'rgba(255,255,255,.26)'; |
| ctx.lineWidth = selected || hovered ? 2 : 1; |
| ctx.stroke(); |
| }}); |
| ctx.globalAlpha = 1; |
| }} |
| function nearestNode(event) {{ |
| const rect = canvas.getBoundingClientRect(); |
| let best = null; |
| let bestDist = Infinity; |
| graph.nodes.forEach(node => {{ |
| const p = toScreen(node); |
| const dx = event.clientX - rect.left - p.x; |
| const dy = event.clientY - rect.top - p.y; |
| const limit = Math.max(13, node.radius * transform.scale + 6); |
| const dist = Math.sqrt(dx * dx + dy * dy); |
| if (dist < limit && dist < bestDist) {{ best = node; bestDist = dist; }} |
| }}); |
| return best; |
| }} |
| function showDetail(node) {{ |
| selectedId = node.id; |
| const detail = document.getElementById('detail'); |
| detail.style.display = 'block'; |
| const connected = Array.from(neighbors.get(node.id) || []).length; |
| detail.innerHTML = `<div class="relation">${{escapeHtml(node.community_label || 'Topic cluster')}} - ${{connected}} nearby papers</div><h2>${{escapeHtml(node.title)}}</h2><div class="byline">${{escapeHtml(node.first_author || node.authors)}} - ${{escapeHtml(node.year)}} - ${{escapeHtml(String(node.citations))}} cites</div><p>${{escapeHtml(node.abstract || 'No abstract was available for this paper.')}}</p><a href="${{escapeAttr(node.url || '#')}}" target="_blank" rel="noopener noreferrer">Open paper</a>`; |
| draw(); |
| }} |
| canvas.addEventListener('mousemove', event => {{ |
| if (dragStart) {{ |
| transform.x = dragStart.originX + (event.clientX - dragStart.x) / transform.scale; |
| transform.y = dragStart.originY + (event.clientY - dragStart.y) / transform.scale; |
| draw(); |
| return; |
| }} |
| const node = nearestNode(event); |
| const nextHover = node ? node.id : null; |
| if (nextHover !== hoverId) {{ hoverId = nextHover; draw(); }} |
| }}); |
| canvas.addEventListener('mousedown', event => {{ |
| dragStart = {{ x: event.clientX, y: event.clientY, originX: transform.x, originY: transform.y }}; |
| canvas.classList.add('dragging'); |
| }}); |
| addEventListener('mouseup', () => {{ dragStart = null; canvas.classList.remove('dragging'); }}); |
| canvas.addEventListener('click', event => {{ |
| if (dragStart) return; |
| const node = nearestNode(event); |
| if (node) showDetail(node); |
| }}); |
| canvas.addEventListener('wheel', event => {{ |
| event.preventDefault(); |
| const point = fromScreen(event.offsetX, event.offsetY); |
| const factor = event.deltaY < 0 ? 1.12 : .9; |
| transform.scale = Math.max(.45, Math.min(2.8, transform.scale * factor)); |
| const after = fromScreen(event.offsetX, event.offsetY); |
| transform.x += after.x - point.x; |
| transform.y += after.y - point.y; |
| draw(); |
| }}, {{ passive: false }}); |
| document.getElementById('meta').textContent = `${{graph.nodes.length}} papers - ${{graph.communities.length}} clusters - ${{graph.edges.length}} relationships - ${{graph.query}}`; |
| document.getElementById('evidence').textContent = graph.data_completeness.edge_policy; |
| const controlsEl = document.getElementById('controls'); |
| Object.entries(relationLabels).forEach(([type, label]) => {{ |
| const count = graph.edges.filter(edge => edge.type === type).length; |
| if (!count) return; |
| const button = document.createElement('button'); |
| button.className = 'chip active'; |
| button.type = 'button'; |
| button.textContent = `${{label}} ${{count}}`; |
| button.addEventListener('click', () => {{ |
| if (activeRelations.has(type)) {{ activeRelations.delete(type); button.classList.remove('active'); }} |
| else {{ activeRelations.add(type); button.classList.add('active'); }} |
| draw(); |
| }}); |
| controlsEl.appendChild(button); |
| }}); |
| const legend = document.getElementById('legend'); |
| graph.communities.forEach(comm => {{ |
| const row = document.createElement('div'); |
| row.className = 'legend-row'; |
| row.innerHTML = `<span class="dot" style="color:${{comm.color}}; background:${{comm.color}}"></span><span>${{escapeHtml(comm.label)}}</span><span>${{comm.count}}</span>`; |
| row.addEventListener('mouseenter', () => {{ activeCommunity = comm.id; draw(); row.classList.add('active'); }}); |
| row.addEventListener('mouseleave', () => {{ activeCommunity = null; draw(); row.classList.remove('active'); }}); |
| row.addEventListener('click', () => {{ activeCommunity = activeCommunity === comm.id ? null : comm.id; draw(); }}); |
| legend.appendChild(row); |
| }}); |
| addEventListener('resize', resize); |
| resize(); |
| </script> |
| </body> |
| </html> |
| """ |
| return ( |
| f'<iframe id="{frame_id}" class="constellation-frame" ' |
| f'sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox" ' |
| f'srcdoc="{html.escape(srcdoc, quote=True)}"></iframe>' |
| ) |
|
|
|
|
| def build_openalex_constellation(query: str) -> tuple[str, str, dict[str, Any] | None, str | None]: |
| graph, error = fetch_openalex_constellation(query) |
| if error: |
| return error, _empty_constellation_html(error), None, None |
| assert graph is not None |
| zip_path = export_corpus_zip(graph) |
| completeness = graph["data_completeness"] |
| status = ( |
| f"Built {completeness['paper_count']} papers and {completeness['edge_count']} edges. " |
| f"Data completeness: {completeness['edge_policy']}." |
| ) |
| return status, _render_constellation_html(graph), graph, zip_path |
|
|
|
|
| def build_search_result_constellation( |
| query: str, |
| results: list[PaperResult], |
| ) -> tuple[str, str, dict[str, Any] | None, str | None]: |
| if not results: |
| message = "Search first, then build a constellation from the current results." |
| return message, _empty_constellation_html(message), None, None |
| graph = build_constellation_from_papers(query or "current search results", results) |
| zip_path = export_corpus_zip(graph) |
| return ( |
| f"Built a keyword fallback constellation from {len(results)} current search results.", |
| _render_constellation_html(graph), |
| graph, |
| zip_path, |
| ) |
|
|
|
|
| def compare_papers_with_ai( |
| left_index: Any, |
| right_index: Any, |
| results: list[PaperResult], |
| ) -> str: |
| try: |
| left = int(left_index) |
| right = int(right_index) |
| except (TypeError, ValueError): |
| return "Choose two papers from the current search results." |
| if left == right: |
| return "Choose two different papers to compare." |
| if left < 0 or right < 0 or left >= len(results) or right >= len(results): |
| return "One of the selected papers is no longer available in the search results." |
|
|
| papers = [results[left], results[right]] |
| contexts = [] |
| for index, paper in enumerate(papers, start=1): |
| abstract = _trim_to_budget(paper.abstract, MAX_ABSTRACT_CHARS, MAX_ABSTRACT_TOKENS) |
| contexts.append( |
| f"[{index}] Title: {paper.title}\n" |
| f"Authors: {paper.authors}\n" |
| f"Year: {paper.year}\n" |
| f"Source: {paper.source}\n" |
| f"Citations: {paper.citations}\n" |
| f"URL: {paper.url}\n" |
| f"Abstract: {abstract or 'No abstract available.'}" |
| ) |
| prompt = ( |
| f"Compare these two papers for a researcher using {MODEL_DISPLAY_NAME}. " |
| "Use only the provided metadata and abstracts. Cover: shared problem, " |
| "method/data differences, claims, limitations, which one to read first " |
| "for which purpose, and open questions. If evidence is missing, say so." |
| ) |
| return synthesize_with_modal(prompt, "\n\n".join(contexts)) |
|
|
|
|
| def clear_search(): |
| table, label, page = _page_view([], 0) |
| prev_update, next_update = _pagination_updates([], page) |
| compare_left, compare_right = _compare_selector_updates([]) |
| graph_message = _empty_constellation_html() |
| return ( |
| "Enter a research topic to begin.", |
| table, |
| "", |
| [], |
| gr.update(choices=[], value=None), |
| label, |
| page, |
| prev_update, |
| next_update, |
| None, |
| "", |
| "", |
| "", |
| "", |
| DEFAULT_ASK_ANSWER, |
| "", |
| DEFAULT_LOAD_STATUS, |
| "", |
| None, |
| "", |
| "", |
| DEFAULT_PAPER_CHAT_ANSWER, |
| compare_left, |
| compare_right, |
| "", |
| DEFAULT_COMPARE_ANSWER, |
| graph_message, |
| None, |
| ) |
|
|
|
|
| |
| CUSTOM_CSS = """ |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:ital,wght@0,600;0,700;1,600&display=swap'); |
| |
| :root { |
| --sl-bg: #0f172a; |
| --sl-bg-deep: #020617; |
| --sl-panel: #1e293b; |
| --sl-panel-soft: #334155; |
| --sl-border: #334155; |
| --sl-border-soft: #1e293b; |
| --sl-text: #f8fafc; |
| --sl-text-bright: #ffffff; |
| --sl-muted: #94a3b8; |
| --sl-accent: #3b82f6; |
| --sl-accent-bright: #60a5fa; |
| --sl-accent-soft: rgba(59, 130, 246, 0.15); |
| --sl-ink: #1e293b; |
| --sl-crimson: #ef4444; |
| --sl-emerald: #10b981; |
| } |
| |
| /* ===== GLOBAL CANVAS ===== */ |
| .gradio-container { |
| background: |
| radial-gradient(1200px 600px at 15% -10%, rgba(59, 130, 246, 0.08), transparent 60%), |
| var(--sl-bg) !important; |
| color: var(--sl-text) !important; |
| font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important; |
| min-height: 100vh; |
| } |
| |
| .main-shell { max-width: 1200px; margin: 0 auto; padding: 0 8px; } |
| |
| /* ===== ACADEMIC HEADER ===== */ |
| .app-header { |
| position: relative; |
| padding: 36px 28px 28px; |
| margin-bottom: 22px; |
| border-radius: 14px; |
| background: var(--sl-panel); |
| border: 1px solid var(--sl-border); |
| border-top: 4px solid var(--sl-accent); |
| overflow: hidden; |
| } |
| |
| .header-row { display: flex; align-items: center; gap: 20px; position: relative; } |
| .crest { |
| width: 64px; height: 64px; |
| border-radius: 12px; |
| background: linear-gradient(135deg, var(--sl-accent-bright), var(--sl-accent)); |
| display: flex; align-items: center; justify-content: center; |
| font-size: 30px; |
| box-shadow: 0 4px 20px rgba(59, 130, 246, 0.35); |
| flex-shrink: 0; |
| } |
| .header-text h1 { |
| margin: 0; |
| color: var(--sl-text-bright); |
| font-family: "Playfair Display", "Georgia", serif; |
| font-size: 38px; |
| font-weight: 700; |
| letter-spacing: -0.5px; |
| line-height: 1.1; |
| } |
| .header-text h1 .accent { color: var(--sl-accent-bright); } |
| .header-text p { |
| margin: 6px 0 0; |
| color: var(--sl-muted); |
| font-size: 14px; |
| } |
| .header-chips { |
| display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; |
| } |
| .header-chip { |
| display: inline-flex; align-items: center; gap: 5px; |
| padding: 4px 11px; |
| border-radius: 6px; |
| font-size: 11.5px; |
| font-weight: 600; |
| background: var(--sl-accent-soft); |
| color: var(--sl-accent-bright); |
| border: 1px solid rgba(59, 130, 246, 0.25); |
| } |
| |
| /* ===== TABS ===== */ |
| .gradio-container .tabs, |
| .gradio-container .tabitem, |
| .gradio-container [role="tabpanel"], |
| .gradio-container .panel, |
| .gradio-container .block, |
| .gradio-container .form, |
| .gradio-container .contain, |
| .gradio-container .wrap:not(:has(input.border-none)), |
| .gradio-container .wrap-inner:not(:has(input.border-none)) { |
| background: var(--sl-bg) !important; |
| color: var(--sl-text) !important; |
| border-color: var(--sl-border) !important; |
| } |
| .tab-nav { |
| border-bottom: 1px solid var(--sl-border) !important; |
| background: var(--sl-panel) !important; |
| border-radius: 8px 8px 0 0 !important; |
| padding: 4px !important; |
| } |
| .gradio-container [role="tab"], |
| .gradio-container .tab-nav button { |
| background: transparent !important; |
| color: var(--sl-muted) !important; |
| border-color: transparent !important; |
| box-shadow: none !important; |
| min-height: 36px !important; |
| } |
| /* include :active and :focus-visible so a clicked tab never flashes white */ |
| .gradio-container [role="tab"]:hover, |
| .gradio-container [role="tab"]:focus, |
| .gradio-container [role="tab"]:focus-visible, |
| .gradio-container [role="tab"]:active, |
| .gradio-container .tab-nav button:hover, |
| .gradio-container .tab-nav button:focus, |
| .gradio-container .tab-nav button:focus-visible, |
| .gradio-container .tab-nav button:active { |
| background: rgba(59, 130, 246, 0.18) !important; |
| color: var(--sl-text-bright) !important; |
| border-color: rgba(96, 165, 250, 0.55) !important; |
| box-shadow: inset 0 -2px 0 var(--sl-accent) !important; |
| } |
| .gradio-container [role="tab"][aria-selected="true"], |
| .gradio-container [role="tab"][aria-selected="true"]:active, |
| .tab-nav button.selected { |
| color: var(--sl-accent-bright) !important; |
| border-bottom: 2px solid var(--sl-accent) !important; |
| background: var(--sl-accent-soft) !important; |
| box-shadow: inset 0 -2px 0 var(--sl-accent) !important; |
| } |
| |
| .gradio-container button[disabled], |
| .gradio-container button:disabled { |
| background: rgba(30, 41, 59, 0.7) !important; |
| color: rgba(148, 163, 184, 0.7) !important; |
| border-color: rgba(51, 65, 85, 0.8) !important; |
| opacity: 1 !important; |
| } |
| .gradio-container .overflow-menu button { |
| color: var(--sl-text-bright) !important; |
| } |
| .gradio-container .overflow-dropdown { |
| background: var(--sl-panel) !important; |
| border: 1px solid var(--sl-border) !important; |
| box-shadow: 0 18px 45px rgba(2, 6, 23, 0.55) !important; |
| } |
| .gradio-container .overflow-dropdown button { |
| background: transparent !important; |
| color: var(--sl-text-bright) !important; |
| } |
| .gradio-container .overflow-dropdown button:hover, |
| .gradio-container .overflow-dropdown button:focus { |
| background: var(--sl-accent-soft) !important; |
| color: var(--sl-accent-bright) !important; |
| } |
| .gradio-container .overflow-dropdown button.selected { |
| background: rgba(59, 130, 246, 0.24) !important; |
| color: var(--sl-text-bright) !important; |
| } |
| |
| /* ===== INPUTS ===== */ |
| .gradio-container input[type=text], |
| .gradio-container input[type=search], |
| .gradio-container textarea { |
| background: var(--sl-bg-deep) !important; |
| border: 1px solid var(--sl-border) !important; |
| color: var(--sl-text-bright) !important; |
| border-radius: 8px !important; |
| } |
| .gradio-container input[type=text]:focus, |
| .gradio-container textarea:focus { |
| border-color: var(--sl-accent) !important; |
| box-shadow: 0 0 0 3px var(--sl-accent-soft) !important; |
| } |
| .readable-select input, |
| .readable-select [role="listbox"] input, |
| .readable-select .secondary-wrap input, |
| .gradio-container input.border-none, |
| .gradio-container .secondary-wrap input { |
| background: #f8fafc !important; |
| color: #0f172a !important; |
| -webkit-text-fill-color: #0f172a !important; |
| opacity: 1 !important; |
| } |
| .readable-select input::placeholder, |
| .gradio-container input.border-none::placeholder, |
| .gradio-container .secondary-wrap input::placeholder { |
| color: #64748b !important; |
| -webkit-text-fill-color: #64748b !important; |
| opacity: 1 !important; |
| } |
| .readable-select .wrap, |
| .readable-select .wrap-inner, |
| .readable-select .secondary-wrap, |
| .gradio-container .wrap:has(input.border-none), |
| .gradio-container .wrap-inner:has(input.border-none), |
| .gradio-container .secondary-wrap:has(input.border-none) { |
| background: #f8fafc !important; |
| color: #0f172a !important; |
| border-color: rgba(96, 165, 250, 0.38) !important; |
| } |
| .readable-select label, |
| .readable-select span { |
| color: var(--sl-muted) !important; |
| } |
| .readable-select [role="option"], |
| .readable-select li, |
| .gradio-container [role="option"], |
| .gradio-container li[role="option"] { |
| color: #0f172a !important; |
| } |
| |
| /* ===== BUTTONS ===== */ |
| .gradio-container button.primary { |
| background: var(--sl-accent) !important; |
| color: white !important; |
| border: none !important; |
| box-shadow: 0 4px 14px rgba(59, 130, 246, 0.30) !important; |
| } |
| .gradio-container button.primary:hover { |
| background: var(--sl-accent-bright) !important; |
| box-shadow: 0 6px 20px rgba(59, 130, 246, 0.45) !important; |
| } |
| .gradio-container button:not(.primary):not([role="tab"]) { |
| background: var(--sl-panel) !important; |
| color: var(--sl-text-bright) !important; |
| border: 1px solid var(--sl-border) !important; |
| } |
| .gradio-container button:not(.primary):not([role="tab"]):hover, |
| .gradio-container button:not(.primary):not([role="tab"]):focus { |
| background: var(--sl-panel-soft) !important; |
| border-color: rgba(96, 165, 250, 0.55) !important; |
| color: var(--sl-text-bright) !important; |
| } |
| |
| /* ===== RESULTS TABLE ===== */ |
| .table-shell { |
| border: 1px solid var(--sl-border); |
| border-radius: 12px; |
| background: var(--sl-panel); |
| overflow-x: auto; |
| overflow-y: hidden; |
| } |
| .results-table { width: 100%; min-width: 860px; border-collapse: collapse; } |
| .results-table th { |
| background: var(--sl-bg-deep); |
| color: var(--sl-accent-bright) !important; |
| text-align: left; |
| font-size: 11px; |
| text-transform: uppercase; |
| padding: 14px 18px; |
| } |
| .results-table td { |
| padding: 16px 18px; |
| border-bottom: 1px solid var(--sl-border-soft); |
| } |
| .select-cell { |
| width: 40px; |
| text-align: center; |
| } |
| .select-cell input[type="radio"] { |
| accent-color: var(--sl-accent); |
| cursor: pointer; |
| width: 18px; |
| height: 18px; |
| } |
| .result-row { transition: background 140ms ease; } |
| .result-row:hover { background: var(--sl-panel-soft); } |
| .result-row.selected { |
| background: var(--sl-accent-soft) !important; |
| border-left: 4px solid var(--sl-accent); |
| } |
| |
| .title-cell { |
| font-family: "Playfair Display", serif; |
| font-weight: 600; |
| color: var(--sl-text-bright) !important; |
| } |
| .num-cell { color: var(--sl-muted); text-align: center; font-variant-numeric: tabular-nums; } |
| |
| /* ===== PAGINATION ===== */ |
| .page-row { align-items: center; justify-content: center; gap: 14px; margin-top: 12px; } |
| .page-label { text-align: center; color: var(--sl-muted) !important; min-width: 180px; } |
| |
| .source-badge.semantic { color: #a78bfa !important; background: rgba(167, 139, 250, 0.12); border: 1px solid rgba(167, 139, 250, 0.35); } |
| .source-badge.openalex { color: #22d3ee !important; background: rgba(34, 211, 238, 0.1); border: 1px solid rgba(34, 211, 238, 0.3); } |
| .source-badge.crossref { color: #fbbf24 !important; background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.3); } |
| .source-badge.arxiv { color: #f87171 !important; background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.3); } |
| .source-badge.pubmed { color: #34d399 !important; background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.3); } |
| |
| .status-line { |
| color: var(--sl-muted) !important; |
| padding: 4px 2px; |
| } |
| .empty-state { |
| min-height: 220px; |
| display: flex; |
| flex-direction: column; |
| justify-content: center; |
| align-items: center; |
| gap: 8px; |
| color: var(--sl-muted) !important; |
| border: 1px dashed rgba(96, 165, 250, 0.25); |
| border-radius: 8px; |
| background: rgba(15, 23, 42, 0.58); |
| text-align: center; |
| padding: 24px; |
| } |
| .empty-state h3 { |
| color: var(--sl-text-bright) !important; |
| margin: 0; |
| } |
| .empty-state p { |
| color: var(--sl-muted) !important; |
| margin: 0; |
| } |
| .empty-crest { |
| font-size: 28px; |
| color: var(--sl-accent-bright); |
| } |
| .insight-grid { |
| display: grid; |
| grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); |
| gap: 10px; |
| margin: 12px 0 14px; |
| } |
| .insight-card { |
| border: 1px solid var(--sl-border); |
| border-radius: 8px; |
| background: var(--sl-panel); |
| padding: 12px 14px; |
| display: flex; |
| flex-direction: column; |
| gap: 4px; |
| } |
| .insight-card span { |
| color: var(--sl-muted); |
| font-size: 11px; |
| text-transform: uppercase; |
| letter-spacing: 0.4px; |
| font-weight: 700; |
| } |
| .insight-card strong { color: var(--sl-text-bright); font-size: 20px; line-height: 1.1; } |
| .insight-card small { color: var(--sl-muted); font-size: 12px; } |
| .action-row { align-items: end; gap: 12px; } |
| .gradio-container [role="option"] { |
| color: rgb(10, 20, 252) !important; |
| } |
| .gradio-container [role="option"]:hover, |
| .gradio-container [role="option"][aria-selected="true"] { |
| color: rgb(10, 20, 252) !important; |
| font-weight: 700 !important; |
| } |
| .action-cell { |
| display: flex; |
| gap: 8px; |
| align-items: center; |
| flex-wrap: wrap; |
| } |
| .btn-crimson button { |
| background: rgba(127, 29, 29, 0.86) !important; |
| border-color: rgba(239, 68, 68, 0.4) !important; |
| color: #fecaca !important; |
| box-shadow: 0 4px 14px rgba(127, 29, 29, 0.25) !important; |
| } |
| .btn-crimson button:hover, |
| .btn-crimson button:focus { |
| background: rgba(185, 28, 28, 0.92) !important; |
| border-color: rgba(248, 113, 113, 0.7) !important; |
| color: #fff !important; |
| } |
| .sample-row { |
| gap: 8px; |
| margin: 6px 0 12px; |
| } |
| .sample-row button { |
| white-space: normal !important; |
| line-height: 1.25 !important; |
| } |
| .summarize-panel { |
| border: 1px solid var(--sl-border); |
| border-radius: 8px; |
| background: var(--sl-panel); |
| padding: 16px; |
| } |
| .about-card { |
| max-width: 820px; |
| border: 1px solid var(--sl-border); |
| border-radius: 8px; |
| background: var(--sl-panel); |
| padding: 22px 24px; |
| line-height: 1.65; |
| } |
| .about-card h2 { margin-top: 0; color: var(--sl-text-bright); } |
| .proof-section { |
| margin-top: 18px; |
| padding-top: 18px; |
| border-top: 1px solid var(--sl-border-soft); |
| } |
| .proof-section h3 { |
| margin: 0 0 12px; |
| color: var(--sl-text-bright); |
| font-size: 15px; |
| } |
| .proof-grid { |
| display: grid; |
| grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); |
| gap: 10px; |
| } |
| .proof-card { |
| border: 1px solid var(--sl-border-soft); |
| border-radius: 8px; |
| padding: 12px; |
| background: rgba(15, 23, 42, 0.38); |
| } |
| .proof-card strong { |
| display: block; |
| color: var(--sl-accent-bright); |
| margin-bottom: 5px; |
| } |
| .proof-card span { |
| color: var(--sl-muted); |
| font-size: 13px; |
| line-height: 1.45; |
| } |
| .download-action { margin-top: 8px; } |
| |
| /* ===== ASK / SYNTHESIS ===== */ |
| .ask-intro { color: var(--sl-muted) !important; margin-bottom: 6px; } |
| .answer-card { |
| background: var(--sl-panel) !important; |
| border: 1px solid var(--sl-border); |
| border-left: 4px solid var(--sl-accent); |
| border-radius: 12px; |
| padding: 20px 24px !important; |
| margin-top: 16px; |
| line-height: 1.65; |
| font-size: 15px; |
| } |
| .answer-card p { color: var(--sl-text) !important; } |
| |
| .refs-shell { |
| margin-top: 18px; |
| border: 1px solid var(--sl-border); |
| border-radius: 12px; |
| background: var(--sl-panel); |
| padding: 16px 20px; |
| } |
| .refs-shell h3 { |
| margin: 0 0 12px; |
| font-size: 13px; |
| text-transform: uppercase; |
| letter-spacing: 0.5px; |
| color: var(--sl-accent-bright); |
| } |
| .refs-list { list-style: none; margin: 0; padding: 0; } |
| .ref-item { display: flex; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--sl-border-soft); } |
| .ref-item:last-child { border-bottom: none; } |
| .ref-num { color: var(--sl-accent-bright); font-weight: 700; flex-shrink: 0; } |
| .ref-body { display: flex; flex-direction: column; gap: 4px; } |
| .ref-link { color: var(--sl-text-bright) !important; font-weight: 600; text-decoration: none; } |
| .ref-link:hover { color: var(--sl-accent-bright) !important; text-decoration: underline; } |
| .ref-meta { color: var(--sl-muted); font-size: 12px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } |
| .ref-snippet { color: var(--sl-text); font-size: 13px; line-height: 1.45; } |
| |
| .constellation-frame { |
| width: 100%; |
| height: min(860px, 78vh); |
| min-height: 620px; |
| border: 1px solid rgba(214, 173, 79, 0.25); |
| border-radius: 8px; |
| background: #000; |
| overflow: hidden; |
| } |
| .constellation-empty { |
| min-height: 420px; |
| border: 1px solid rgba(214, 173, 79, 0.22); |
| border-radius: 8px; |
| background: |
| radial-gradient(circle at 50% 35%, rgba(214, 173, 79, 0.08), transparent 34%), |
| #020617; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| text-align: center; |
| color: var(--sl-muted); |
| } |
| .constellation-empty h3 { |
| margin: 0 0 8px; |
| color: var(--sl-text-bright); |
| font-family: "Playfair Display", "Georgia", serif; |
| font-size: 30px; |
| } |
| .constellation-empty p { max-width: 520px; margin: 0; } |
| |
| /* (UI/UX polish layer reverted - it caused a layout regression) */ |
| |
| /* Remove system buttons and footers */ |
| .settings, footer, .show-api { display: none !important; } |
| |
| .hidden-component { display: none !important; } |
| """ |
|
|
| |
| |
| HEAD_SCRIPT = """ |
| <script> |
| function selectPaper(event, index) { |
| if (event && event.target && event.target.closest('a')) { |
| return; |
| } |
| const input = document.querySelector('#hidden_index_input textarea'); |
| if (input) { |
| // Use the framework's native value setter so Gradio/Svelte actually |
| // registers the change, then fire `input` to trigger the .input() |
| // handler. No setTimeout/second-click race. |
| const setter = Object.getOwnPropertyDescriptor( |
| window.HTMLTextAreaElement.prototype, 'value' |
| ).set; |
| setter.call(input, String(index)); |
| input.dispatchEvent(new Event('input', { bubbles: true })); |
| input.dispatchEvent(new Event('change', { bubbles: true })); |
| } |
| document.querySelectorAll('.result-row').forEach(r => r.classList.remove('selected')); |
| const row = document.getElementById('row-' + index); |
| if (row) row.classList.add('selected'); |
| } |
| |
| function selectPaperFromKey(event, index) { |
| if (event.key === 'Enter' || event.key === ' ') { |
| event.preventDefault(); |
| selectPaper(event, index); |
| } |
| } |
| </script> |
| """ |
|
|
|
|
| def build_app() -> tuple[gr.Blocks, gr.themes.Base]: |
| theme = gr.themes.Base( |
| primary_hue="blue", |
| neutral_hue="slate", |
| font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui"], |
| ).set( |
| body_background_fill="#0f172a", |
| body_text_color="#f8fafc", |
| block_background_fill="#1e293b", |
| block_border_color="#334155", |
| button_primary_background_fill="#3b82f6", |
| button_primary_text_color="#ffffff", |
| ) |
|
|
| with gr.Blocks(title=APP_TITLE) as app: |
| with gr.Column(elem_classes=["main-shell"]): |
| gr.HTML( |
| f""" |
| <header class="app-header"> |
| <div class="header-row"> |
| <div class="crest">🔬</div> |
| <div class="header-text"> |
| <h1>Scholar <span class="accent">Lens</span></h1> |
| <p>Small-model literature review for atmospheric science</p> |
| <div class="header-chips"> |
| <span class="header-chip">Semantic Scholar</span> |
| <span class="header-chip">OpenAlex</span> |
| <span class="header-chip">Crossref</span> |
| <span class="header-chip">arXiv</span> |
| <span class="header-chip">PubMed</span> |
| <span class="header-chip">{MODEL_PROVIDER_BADGE}</span> |
| </div> |
| </div> |
| </div> |
| </header> |
| """ |
| ) |
|
|
| papers_state = gr.State([]) |
| page_state = gr.State(0) |
| graph_state = gr.State(None) |
|
|
| with gr.Tabs(selected="ask") as app_tabs: |
| with gr.Tab("💬 Ask", id="ask"): |
| gr.Markdown( |
| f"Ask a research question. Scholar Lens searches **OpenAlex, " |
| f"Crossref, arXiv, and PubMed**, then **{MODEL_DISPLAY_NAME}** " |
| f"writes a synthesized, **cited** answer grounded only in the " |
| f"retrieved abstracts.", |
| elem_classes=["ask-intro"], |
| ) |
| with gr.Row(): |
| question_input = gr.Textbox( |
| label="Research question", |
| placeholder="e.g. What are the main approaches to early Alzheimer's detection from MRI, and where do they disagree?", |
| scale=5, |
| container=False, |
| lines=2, |
| max_length=ASK_QUESTION_CHAR_LIMIT, |
| ) |
| ask_button = gr.Button("💬 Ask", variant="primary", scale=1) |
|
|
| answer_output = gr.Markdown( |
| DEFAULT_ASK_ANSWER, |
| elem_classes=["answer-card"], |
| ) |
| references_output = gr.HTML() |
|
|
| with gr.Row(elem_classes=["sample-row"]): |
| for sample_question in SAMPLE_QUESTIONS: |
| sample_button = gr.Button(sample_question, size="sm", scale=1) |
| sample_button.click( |
| fn=lambda sample=sample_question: ask_example(sample), |
| outputs=[question_input, answer_output, references_output], |
| show_progress="full", |
| ) |
|
|
| ask_button.click( |
| fn=ask_scholar_lens, |
| inputs=question_input, |
| outputs=[answer_output, references_output], |
| show_progress="full", |
| ) |
| question_input.submit( |
| fn=ask_scholar_lens, |
| inputs=question_input, |
| outputs=[answer_output, references_output], |
| show_progress="full", |
| ) |
|
|
| with gr.Tab("Compare", id="compare"): |
| gr.Markdown( |
| f"Search for papers, choose any two results, and ask {MODEL_DISPLAY_NAME} to compare their methods, claims, limitations, and best use cases.", |
| elem_classes=["ask-intro"], |
| ) |
| with gr.Row(elem_classes=["action-row"]): |
| compare_left_selector = gr.Dropdown( |
| label="Paper A", |
| choices=[], |
| type="index", |
| interactive=True, |
| scale=1, |
| elem_classes=["readable-select"], |
| ) |
| compare_right_selector = gr.Dropdown( |
| label="Paper B", |
| choices=[], |
| type="index", |
| interactive=True, |
| scale=1, |
| elem_classes=["readable-select"], |
| ) |
| compare_button = gr.Button("Compare with Nemotron", variant="primary", scale=0, min_width=190) |
| compare_output = gr.Markdown( |
| DEFAULT_COMPARE_ANSWER, |
| elem_classes=["answer-card"], |
| ) |
| compare_button.click( |
| fn=compare_papers_with_ai, |
| inputs=[compare_left_selector, compare_right_selector, papers_state], |
| outputs=compare_output, |
| show_progress="full", |
| ) |
|
|
| with gr.Tab("🔍 Search", id="search"): |
| with gr.Row(): |
| query_input = gr.Textbox( |
| label="Search topic or keywords", |
| placeholder="One topic, or several keywords separated by commas/new lines", |
| scale=5, |
| container=False, |
| lines=2, |
| max_length=SEARCH_QUERY_CHAR_LIMIT, |
| ) |
| search_button = gr.Button("🔍 Search", variant="primary", scale=1) |
| with gr.Column(scale=0, min_width=100, elem_classes=["btn-crimson"]): |
| clear_button = gr.Button("Clear") |
|
|
| status_output = gr.Markdown("Ready for search.", elem_classes=["status-line"]) |
| insights_output = gr.HTML() |
| results_output = gr.HTML(_render_results_table([])) |
| with gr.Row(): |
| results_download = gr.DownloadButton( |
| "Download Results CSV", |
| value=None, |
| size="sm", |
| elem_classes=["download-action"], |
| ) |
| bibtex_download = gr.DownloadButton( |
| "Download BibTeX", |
| value=None, |
| size="sm", |
| elem_classes=["download-action"], |
| ) |
|
|
| with gr.Row(elem_classes=["page-row"]): |
| prev_button = gr.Button("← Prev", scale=0, min_width=100, interactive=False) |
| page_label = gr.Markdown("", elem_classes=["page-label"]) |
| next_button = gr.Button("Next →", scale=0, min_width=100, interactive=False) |
|
|
| with gr.Row(elem_classes=["action-row"]): |
| paper_selector = gr.Dropdown( |
| label="Select a paper to summarize", |
| choices=[], |
| type="index", |
| interactive=True, |
| scale=4, |
| elem_classes=["readable-select"], |
| ) |
| with gr.Column(scale=1): |
| summarize_selected_button = gr.Button("✨ Summarize Now", variant="primary") |
|
|
| search_outputs = [ |
| status_output, |
| results_output, |
| insights_output, |
| papers_state, |
| paper_selector, |
| page_label, |
| page_state, |
| prev_button, |
| next_button, |
| results_download, |
| compare_left_selector, |
| compare_right_selector, |
| ] |
| search_button.click( |
| fn=search_all_sources, |
| inputs=query_input, |
| outputs=search_outputs, |
| show_progress="full", |
| ) |
| query_input.submit( |
| fn=search_all_sources, |
| inputs=query_input, |
| outputs=search_outputs, |
| show_progress="full", |
| ) |
|
|
| prev_button.click( |
| fn=lambda results, page: change_page(results, page, -1), |
| inputs=[papers_state, page_state], |
| outputs=[results_output, page_label, page_state, prev_button, next_button], |
| ) |
| next_button.click( |
| fn=lambda results, page: change_page(results, page, 1), |
| inputs=[papers_state, page_state], |
| outputs=[results_output, page_label, page_state, prev_button, next_button], |
| ) |
| bibtex_download.click( |
| fn=export_bibtex, |
| inputs=papers_state, |
| outputs=bibtex_download, |
| ) |
|
|
| hidden_selected_index = gr.Textbox( |
| label="Selected row index", |
| elem_id="hidden_index_input", |
| elem_classes=["hidden-component"], |
| ) |
|
|
| with gr.Tab("Constellation", id="constellation"): |
| gr.Markdown( |
| "Start the demo here: build a literature constellation, pick two papers, then use Nemotron for grounded comparison.", |
| elem_classes=["ask-intro"], |
| ) |
| with gr.Row(): |
| graph_query = gr.Textbox( |
| label="Constellation topic", |
| value="", |
| placeholder="e.g. atmospheric rivers, satellite precipitation, aerosol-cloud interactions", |
| scale=5, |
| container=False, |
| max_length=SEARCH_QUERY_CHAR_LIMIT, |
| ) |
| build_graph_button = gr.Button("Build Map", variant="primary", scale=1) |
| with gr.Row(): |
| build_from_search_button = gr.Button("Use Current Search Results", size="sm") |
| corpus_download = gr.DownloadButton( |
| "Download Corpus ZIP", |
| value=None, |
| size="sm", |
| elem_classes=["download-action"], |
| ) |
| graph_status = gr.Markdown( |
| "Build a constellation from OpenAlex or from current search results.", |
| elem_classes=["status-line"], |
| ) |
| graph_output = gr.HTML(_empty_constellation_html()) |
|
|
| build_graph_button.click( |
| fn=build_openalex_constellation, |
| inputs=graph_query, |
| outputs=[graph_status, graph_output, graph_state, corpus_download], |
| show_progress="full", |
| ) |
| graph_query.submit( |
| fn=build_openalex_constellation, |
| inputs=graph_query, |
| outputs=[graph_status, graph_output, graph_state, corpus_download], |
| show_progress="full", |
| ) |
| build_from_search_button.click( |
| fn=build_search_result_constellation, |
| inputs=[query_input, papers_state], |
| outputs=[graph_status, graph_output, graph_state, corpus_download], |
| show_progress="full", |
| ) |
|
|
| with gr.Tab("✨ Summarize", id="summarize"): |
| with gr.Column(elem_classes=["summarize-panel"]): |
| load_status_output = gr.Markdown(DEFAULT_LOAD_STATUS) |
| source_text = gr.Textbox( |
| label="Paper Context / Abstract", |
| lines=10, |
| max_length=SUMMARY_INPUT_CHAR_LIMIT, |
| buttons=["copy"], |
| ) |
| results_text = gr.Textbox( |
| label="Results / Findings Section (optional)", |
| placeholder="Paste the paper's results, findings, discussion, or conclusion section here when available.", |
| lines=6, |
| max_length=SUMMARY_INPUT_CHAR_LIMIT, |
| buttons=["copy"], |
| ) |
| summarize_button = gr.Button("✨ Summarize with AI", variant="primary") |
| summary_output = gr.Textbox( |
| label="AI Synthesis", |
| lines=8, |
| interactive=False, |
| buttons=["copy"], |
| ) |
| summary_download = gr.DownloadButton( |
| "Download Summary Markdown", |
| value=None, |
| size="sm", |
| elem_classes=["download-action"], |
| ) |
| chat_question = gr.Textbox( |
| label="Talk with AI about this paper", |
| placeholder="Ask about methods, assumptions, findings, limitations, or what the results mean.", |
| lines=2, |
| max_length=ASK_QUESTION_CHAR_LIMIT, |
| ) |
| chat_button = gr.Button("Ask About Paper", variant="primary") |
| chat_output = gr.Markdown( |
| DEFAULT_PAPER_CHAT_ANSWER, |
| elem_classes=["answer-card"], |
| ) |
|
|
| summarize_button.click( |
| fn=summarize_paper_context, |
| inputs=[source_text, results_text], |
| outputs=summary_output, |
| show_progress="full", |
| ) |
| summary_download.click( |
| fn=export_summary_markdown, |
| inputs=[source_text, results_text, summary_output], |
| outputs=summary_download, |
| ) |
| chat_button.click( |
| fn=chat_about_paper, |
| inputs=[source_text, results_text, chat_question], |
| outputs=chat_output, |
| show_progress="full", |
| ) |
| chat_question.submit( |
| fn=chat_about_paper, |
| inputs=[source_text, results_text, chat_question], |
| outputs=chat_output, |
| show_progress="full", |
| ) |
| |
| |
| |
| summarize_selected_button.click( |
| fn=load_selected_paper_reset_chat, |
| inputs=[paper_selector, papers_state], |
| outputs=[ |
| source_text, |
| load_status_output, |
| summary_output, |
| app_tabs, |
| results_text, |
| chat_question, |
| chat_output, |
| ], |
| show_progress="full", |
| ) |
| |
| |
| hidden_selected_index.input( |
| fn=summarize_row_selection, |
| inputs=[hidden_selected_index, papers_state], |
| outputs=[ |
| source_text, |
| load_status_output, |
| summary_output, |
| app_tabs, |
| paper_selector, |
| results_text, |
| chat_question, |
| chat_output, |
| ], |
| show_progress="full", |
| ) |
|
|
| with gr.Tab("📖 About"): |
| gr.HTML( |
| f""" |
| <div class="about-card"> |
| <h2>🎓 About Scholar Lens</h2> |
| <p> |
| Built for a real <strong>atmospheric-science professor</strong> who was |
| losing hours every week juggling four paper databases — copying |
| abstracts, losing track, re-reading the same studies. <strong>Scholar |
| Lens</strong> turns one research question into a cited, cross-database |
| answer in seconds. |
| </p> |
| <p> |
| It is a small-model academic discovery engine: the model does the |
| reading and synthesis, not just decoration. |
| </p> |
| <p> |
| It performs real-time searches across <strong>OpenAlex</strong>, |
| <strong>Crossref</strong>, <strong>arXiv</strong>, and <strong>PubMed</strong> |
| using parallel processing, then uses <strong>{MODEL_DISPLAY_NAME}</strong>, |
| hosted on Modal, to do the heavy lifting. |
| </p> |
| <p> |
| In the <strong>Ask</strong> tab the model answers your research |
| question with a synthesized, <strong>cited</strong> response grounded |
| only in the retrieved abstracts — so it compares findings across |
| papers without inventing sources. The <strong>Summarize</strong> tab |
| condenses any single paper or pasted text. |
| </p> |
| <p style="margin-top: 16px; font-size: 13px; color: var(--sl-muted);"> |
| Built for the Hugging Face <em>Build Small</em> hackathon · |
| {MODEL_PROVIDER_BADGE} · model small enough for the consumer-GPU story. |
| </p> |
| {_render_rubric_proof()} |
| </div> |
| """ |
| ) |
| |
| clear_button.click( |
| fn=clear_search, |
| outputs=[ |
| status_output, |
| results_output, |
| insights_output, |
| papers_state, |
| paper_selector, |
| page_label, |
| page_state, |
| prev_button, |
| next_button, |
| results_download, |
| source_text, |
| results_text, |
| summary_output, |
| query_input, |
| question_input, |
| answer_output, |
| references_output, |
| load_status_output, |
| hidden_selected_index, |
| summary_download, |
| chat_question, |
| chat_output, |
| compare_left_selector, |
| compare_right_selector, |
| graph_status, |
| compare_output, |
| graph_output, |
| corpus_download, |
| ], |
| ) |
|
|
| return app, theme |
|
|
|
|
| if __name__ == "__main__": |
| print("Starting app...") |
| app, theme = build_app() |
| print("App built, launching...") |
| app.queue().launch( |
| server_name="0.0.0.0", |
| server_port=7860, |
| theme=theme, |
| css=CUSTOM_CSS, |
| head=HEAD_SCRIPT, |
| ) |
|
|