Build a Literature Search Assistant

Search 200M+ academic papers and synthesize findings with Gemini AI.

Introduction

30 Second Summary

When you're researching a topic, you often end up drowning in search results. You open dozens of tabs, skim abstracts, and try to mentally piece together what the research actually says.

In this project, you will build a Python command-line tool that searches over 200 million academic papers, summarizes them with AI, and discovers related work you would never find through keyword search alone.

What You'll Build

Run your assistant from the terminal to search any research topic, review papers with citation counts and AI-generated summaries, and turn the field's findings into a two-minute briefing.

By the end of this project, you'll have:

  • A paper search that queries the Semantic Scholar API and displays results with titles, authors, citation counts, and one-line TLDR summaries.
  • An AI-powered synthesis feature where you ask for key findings and get back a multi-paragraph briefing that pulls themes across all your papers, powered by Google Gemini.
  • A paper discovery tool that takes any paper from your results and surfaces related work through Semantic Scholar's recommendation engine, finding papers keyword search would miss.
  • Secret Mission: Add search filters like year range, minimum citations, and open access only to narrow results like a pro researcher.

Are there any prerequisites?

Bring a Google account. Step 1 guides you through Python, Visual Studio Code, the project environment, and secure Gemini API key storage.

Before We Start

Before We Start...

Before we dive in, let's get clear on what you're building and why it matters. Taking a moment to articulate the goal now means every step from here on connects back to a clear purpose.

Set Up Your Environment

Before your assistant can search papers, your computer needs a predictable place to run it. Your environment is the project folder, Python virtual environment, packages, code editor, and files that keep this project isolated from other Python work.

In this step, you'll create that environment and store your Gemini API key without exposing it in code. The finished setup gives every later step the same files and dependencies to build on.

In this step, get ready to:
  • Confirm that Python 3.10 or newer and a code editor are available.
  • Create an isolated project folder and install its packages.
  • Create a Gemini API key and store it safely.
Check Python and your code editor
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows), then press Enter.
  • Check your installed Python version using the tab for your computer:

macOS

  • Check your Python version by running this command:
python3 --version

Windows

  • Check your Python version by running this command:
py --version

Choose the tab that matches the result in your terminal.

✔️ I see Python 3.10 or newer

Your Python version can run both packages used in this project.

ⓧ I see an older version

  • Open the official Python downloads page.
  • Download the latest stable Python 3 installer for your operating system.
  • Run the installer and complete its default installation flow.
  • Close your terminal after the installation finishes.
  • Open a new terminal and run the version check again.

ⓧ The command is not found

  • Open the official Python downloads page.
  • Download the latest stable Python 3 installer for your operating system.
  • Run the installer and complete its default installation flow.
  • Close your terminal after the installation finishes.
  • Open a new terminal and run the version check again.

You’ll use Visual Studio Code to create and update literature_assistant.py. The editor is free and keeps every project file in one view.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter.

✔️ Visual Studio Code opens

Your editor is ready for the project files you'll create next.

ⓧ Visual Studio Code is not installed

  • Open the official Visual Studio Code download page.
  • Download the installer for your operating system.
  • Run the installer and complete its default installation flow.
  • Open Visual Studio Code through your operating system's search bar.
Create the isolated project

A virtual environment gives this project its own package space. It prevents the Gemini and HTTP libraries from changing other Python projects on your computer.

macOS

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder and confirm its location by running these commands:
mkdir literature-search-assistant
cd literature-search-assistant
pwd

You'll see a path ending in Desktop/literature-search-assistant.

  • Create and activate the virtual environment by running these commands:
python3 -m venv .venv
source .venv/bin/activate
python --version

You'll see (.venv) at the start of your terminal prompt and Python 3.10 or newer in the output.

  • Install the project packages and verify their imports by running these commands:
python -m pip install "google-genai>=2.4,<3" requests
python -c "import requests; from google import genai; print('Environment ready')"

The installation can take a minute while pip downloads the packages. You'll see Environment ready when both imports work.

  • Create the three project files and list them by running these commands:
touch literature_assistant.py .env .gitignore
ls -a

You'll see literature_assistant.py, .env, and .gitignore in the output.

Windows

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder and confirm its location by running these commands:
New-Item -ItemType Directory literature-search-assistant
Set-Location literature-search-assistant
Get-Location

You'll see a path ending in Desktop\literature-search-assistant.

  • Create and activate the virtual environment by running these commands:
py -m venv .venv
.venv\Scripts\Activate.ps1
python --version

You'll see (.venv) at the start of your PowerShell prompt and Python 3.10 or newer in the output.

  • Install the project packages and verify their imports by running these commands:
python -m pip install "google-genai>=2.4,<3" requests
python -c "import requests; from google import genai; print('Environment ready')"

The installation can take a minute while pip downloads the packages. You'll see Environment ready when both imports work.

  • Create the three project files and list them by running these commands:
New-Item -ItemType File literature_assistant.py, .env, .gitignore
Get-ChildItem -Force

You'll see literature_assistant.py, .env, and .gitignore in the output.

Virtual environment not activating?

PowerShell can block Activate.ps1 when scripts are disabled. The temporary fix below applies only to this terminal session and disappears when you close it.

  • Allow locally created scripts in the current PowerShell session by running:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process
  • Activate the virtual environment and confirm its Python version by running:
.venv\Scripts\Activate.ps1
python --version

You’ll see (.venv) at the start of the prompt and Python 3.10 or newer in the output. Help me activate this environment safely.

On macOS, confirm that the terminal is inside literature-search-assistant before running the activation command again.

  • In Visual Studio Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the literature-search-assistant folder on your Desktop.
  • Click Open.

The Explorer sidebar now shows your three project files and the .venv folder.

Create and protect your Gemini API key

An API key lets Google connect requests to your project. This step touches a credential, but you never paste it into this tutorial or show it in a screenshot.

  • Open the official Google AI Studio API Keys page.
  • Sign in with your Google account.
  • Accept the Google AI Studio terms if this is your first visit.
  • Choose the tab that matches what you see:

✔️ I see an API key

  • Copy the existing key from the API key table.
  • Keep the API Keys page open until you save the key locally.

ⓧ I need a new API key

  • Click Create API key.
  • Follow the dialog to add a key to your project.
  • Copy the new key after it appears in the table.

ⓧ My project is missing

  • Open Dashboard from the left panel.
  • Select Projects.
  • Click Import projects.
  • Select your Google Cloud project.
  • Click Import.
  • Return to the API Keys page and create the key.

Stay on the free tier

Leave the project on the Free Tier for this tutorial. Set up billing is optional and only needed when you want paid quota.

  • Open .env from the Explorer sidebar.
  • Add the environment variable using this line:
GEMINI_API_KEY=your-api-key-here
  • Replace your-api-key-here with the key you copied.
  • Save .env.
  • Open .gitignore from the Explorer sidebar.
  • Protect the key and generated files by adding these lines:
.env
.venv/
__pycache__/
  • Save .gitignore.

Why use .env and .gitignore?

The .env file keeps your key outside literature_assistant.py. Your script can read the credential without hard-coding it.

The .gitignore entry prevents Git from including .env in a future commit. This protects the key if you later upload the project to GitHub.

  • Verify that .env contains a replacement value without printing the key by running this command in the Visual Studio Code terminal:
python -c "from pathlib import Path; lines = Path('.env').read_text().splitlines(); value = next((line.split('=', 1)[1].strip() for line in lines if line.startswith('GEMINI_API_KEY=')), ''); print('Gemini key saved' if value and value != 'your-api-key-here' else 'Key missing')"

You'll see Gemini key saved without exposing the credential. Your setup is now ready for the assistant code.

Seeing Key missing?

Confirm the filename is exactly .env and the line starts with GEMINI_API_KEY=.

Check that you replaced the placeholder and saved the file. Help me check my .env file without exposing my key.

Your project now has an isolated Python environment, the required packages, and a protected Gemini API key. Next, you'll turn the empty script into an academic paper search tool.

Search Papers by Topic

Your project folder, virtual environment, dependencies, and protected Gemini key are ready. Now you'll use that setup to build the assistant's first working feature: academic paper search.

The Semantic Scholar database holds over 200 million academic papers. In this step, you'll call its API and turn the response into formatted papers with useful metadata.

In this step, get ready to:
  • Search academic papers using the Semantic Scholar API.
  • Display formatted results with titles, authors, and citation counts.
  • Run the tool and find papers on a topic of your choice.

Use an optional API key

Most Semantic Scholar endpoints work without authentication. Anonymous requests share capacity with other users, so busy periods can cause throttling.

You can request a key from the Semantic Scholar API page. The key arrives by email with no guaranteed delivery time, so continue with the project while you wait.

Treat the key like a password. If it arrives, add SEMANTIC_SCHOLAR_API_KEY=your-api-key-here to your .env file, replace the placeholder, and keep that file out of source control.

Write the search function

The script first loads .env, which makes the Gemini key and any optional Semantic Scholar key available. The requests and time modules handle HTTP calls and short retry pauses.

  • Open literature_assistant.py in VS Code and add the imports and API key loader by pasting this code:
import os
import time
import requests

from google import genai


def load_api_key():
    """Load environment values and return the Gemini API key."""
    # Find the .env file beside this script.
    env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
    if os.path.exists(env_path):
        # Copy each configured value into the process environment.
        with open(env_path) as f:
            for line in f:
                line = line.strip()
                if line and not line.startswith("#") and "=" in line:
                    key, value = line.split("=", 1)
                    os.environ[key.strip()] = value.strip()
    return os.environ.get("GEMINI_API_KEY")

What does this code do?

The load_api_key() function reads .env and places each configured value in the process environment. This makes both API-key names available without placing their values in source code.

The function returns GEMINI_API_KEY because the main program requires it later. The Semantic Scholar request helper reads SEMANTIC_SCHOLAR_API_KEY only when that optional value exists.

The REST API can answer requests with or without a personal key. A shared helper can add optional authentication, enforce a timeout, and retry when Semantic Scholar returns a rate-limit response.

  • Add the shared request helper below load_api_key() by pasting this code:
def request_semantic_scholar(url, params, error_label):
    """Request Semantic Scholar data with optional authentication."""
    # Attach a personal key only when one exists.
    api_key = os.environ.get("SEMANTIC_SCHOLAR_API_KEY")
    headers = {"x-api-key": api_key} if api_key else {}

    # Retry rate-limit responses before giving up.
    for attempt in range(3):
        try:
            response = requests.get(url, params=params, headers=headers, timeout=15)
        except requests.RequestException as error:
            print(f"Error: {error_label} request failed: {error}")
            return {}

        if response.status_code == 200:
            return response.json()

        if response.status_code == 429 and attempt < 2:
            wait_seconds = 2 ** (attempt + 1)
            print(f"Rate limited. Retrying in {wait_seconds} seconds...")
            time.sleep(wait_seconds)
            continue

        print(f"Error: {error_label} returned status {response.status_code}")
        return {}

    return {}

What does this helper do?

  • headers contains the official x-api-key header only when SEMANTIC_SCHOLAR_API_KEY exists.
  • timeout=15 stops an unresponsive request after 15 seconds.
  • A 429 response triggers waits of two and four seconds before the helper gives up.
  • error_label identifies which API call failed without exposing either key.
  • Add the search function below request_semantic_scholar() by pasting this code:
def search_papers(query, filters=None):
    """Search Semantic Scholar for papers matching the query."""
    url = "https://api.semanticscholar.org/graph/v1/paper/search"
    # Request the metadata displayed later in the program.
    params = {
        "query": query,
        "fields": "title,authors,year,citationCount,abstract,tldr,url,paperId",
        "limit": 10,
    }

    # Reuse optional authentication, timeout, and retry handling.
    data = request_semantic_scholar(url, params, "Search API")
    return data.get("data", [])

What does the search function do?

The params dictionary asks for the fields used by the assistant and limits the response to ten papers. The request helper returns decoded JSON data after handling authentication and throttling.

The search endpoint stores matching papers under data. Returning an empty list keeps the display function predictable if the request fails.

  • Save literature_assistant.py.
  • Verify the search function works by running this quick test in your terminal:
python -c "from literature_assistant import load_api_key, search_papers; load_api_key(); papers = search_papers('neural networks'); print(len(papers), 'papers found')"

You should see something like 10 papers found printed. This confirms your function is calling the API and getting results back.

Still seeing 0 papers or an error?

  • If the helper reports 429 after its retries, wait one minute before testing again. Add the optional Semantic Scholar key later for an individual rate limit.
  • If the request times out, confirm your internet connection before running the test again.
  • If you see NameError for request_semantic_scholar, confirm the helper appears above search_papers() in literature_assistant.py.

Why is my Semantic Scholar search still returning no papers?

Build the display and input loop

Right now the API returns raw dictionaries. You need a function that formats each paper into something readable, showing the title, authors, year, citations, and a short summary.

  • Add the display function below search_papers() by pasting this code:
def display_results(papers):
    """Display papers in a formatted list."""
    if not papers:
        print("\nNo papers found.")
        return

    print(f"\n{'='*70}")
    print(f"Found {len(papers)} papers:")
    print(f"{'='*70}\n")

    for i, paper in enumerate(papers, 1):
        title = paper.get("title", "Untitled")
        year = paper.get("year", "N/A")
        citations = paper.get("citationCount", 0)

        authors_list = paper.get("authors", [])
        if authors_list:
            first_author = authors_list[0].get("name", "Unknown")
            if len(authors_list) > 1:
                authors = f"{first_author} et al."
            else:
                authors = first_author
        else:
            authors = "Unknown"

        tldr = paper.get("tldr")
        tldr_text = tldr.get("text", "") if tldr else ""

        print(f"  [{i}] {title}")
        print(f"      {authors} | {year} | Cited by {citations}")
        if tldr_text:
            print(f"      TLDR: {tldr_text[:150]}...")
        print()

What does this code do?

The display_results() function loops through each paper and prints a numbered entry. It handles missing data gracefully by using defaults like "Untitled" and "Unknown".

For authors, it shows the first author followed by "et al." if there are multiple contributors. The TLDR field is an AI-generated summary that Semantic Scholar provides for many papers.

Now you need a main() function that ties everything together: load the key, ask for a topic, search, and display.

  • Add the main function and entry point below display_results() by pasting this code:
def main():
    api_key = load_api_key()
    if not api_key:
        print("Error: GEMINI_API_KEY not found. Create a .env file with your key.")
        return

    print("\n" + "=" * 70)
    print("  LITERATURE SEARCH ASSISTANT")
    print("=" * 70)

    query = input("\n  Enter research topic: ").strip()
    if query:
        print(f"\n  Searching for '{query}'...")
        papers = search_papers(query)
        display_results(papers)


if __name__ == "__main__":
    main()

What does this code do?

The main() function first loads your API key and stops with a helpful error if it's missing. Then it prints a header and asks for a research topic via input().

The if __name__ == "__main__" block ensures main() only runs when you execute the file directly, not when you import it (like the test you ran earlier).

  • Save literature_assistant.py.

✔️ Awesome, I've got everything!

Great. Double-check that you've saved literature_assistant.py before you run it.

ⓧ I'd like to double check the full code

import os
import time
import requests

from google import genai

def load_api_key():
    """Load environment values and return the Gemini API key."""
    # Find the .env file beside this script.
    env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
    if os.path.exists(env_path):
        # Copy each configured value into the process environment.
        with open(env_path) as f:
            for line in f:
                line = line.strip()
                if line and not line.startswith("#") and "=" in line:
                    key, value = line.split("=", 1)
                    os.environ[key.strip()] = value.strip()
    return os.environ.get("GEMINI_API_KEY")

def request_semantic_scholar(url, params, error_label):
    """Request Semantic Scholar data with optional authentication."""
    # Attach a personal key only when one exists.
    api_key = os.environ.get("SEMANTIC_SCHOLAR_API_KEY")
    headers = {"x-api-key": api_key} if api_key else {}

    # Retry rate-limit responses before giving up.
    for attempt in range(3):
        try:
            response = requests.get(url, params=params, headers=headers, timeout=15)
        except requests.RequestException as error:
            print(f"Error: {error_label} request failed: {error}")
            return {}

        if response.status_code == 200:
            return response.json()

        if response.status_code == 429 and attempt < 2:
            wait_seconds = 2 ** (attempt + 1)
            print(f"Rate limited. Retrying in {wait_seconds} seconds...")
            time.sleep(wait_seconds)
            continue

        print(f"Error: {error_label} returned status {response.status_code}")
        return {}

    return {}

def search_papers(query, filters=None):
    """Search Semantic Scholar for papers matching the query."""
    url = "https://api.semanticscholar.org/graph/v1/paper/search"
    # Request the metadata displayed later in the program.
    params = {
        "query": query,
        "fields": "title,authors,year,citationCount,abstract,tldr,url,paperId",
        "limit": 10,
    }

    # Reuse optional authentication, timeout, and retry handling.
    data = request_semantic_scholar(url, params, "Search API")
    return data.get("data", [])

def display_results(papers):
    """Display papers in a formatted list."""
    if not papers:
        print("\nNo papers found.")
        return

    print(f"\n{'='*70}")
    print(f"Found {len(papers)} papers:")
    print(f"{'='*70}\n")

    for i, paper in enumerate(papers, 1):
        title = paper.get("title", "Untitled")
        year = paper.get("year", "N/A")
        citations = paper.get("citationCount", 0)

        authors_list = paper.get("authors", [])
        if authors_list:
            first_author = authors_list[0].get("name", "Unknown")
            authors = f"{first_author} et al." if len(authors_list) > 1 else first_author
        else:
            authors = "Unknown"

        tldr = paper.get("tldr")
        tldr_text = tldr.get("text", "") if tldr else ""

        print(f"  [{i}] {title}")
        print(f"      {authors} | {year} | Cited by {citations}")
        if tldr_text:
            print(f"      TLDR: {tldr_text[:150]}...")
        print()

def main():
    """Run the literature search assistant."""
    api_key = load_api_key()
    if not api_key:
        print("Error: GEMINI_API_KEY not found. Create a .env file with your key.")
        return

    print("\n" + "=" * 70)
    print("  LITERATURE SEARCH ASSISTANT")
    print("=" * 70)

    query = input("\n  Enter research topic: ").strip()
    if query:
        print(f"\n  Searching for '{query}'...")
        papers = search_papers(query)
        display_results(papers)

if __name__ == "__main__":
    main()
Run it

Your script is complete. Before you run it, what do you expect ten paper results with metadata and TLDRs to look like?

  • Run your literature assistant by executing this command:
python literature_assistant.py
  • When prompted, type a research topic like transformer attention and press Enter.

You should see a numbered list of papers with titles, authors, year, citation count, and TLDR summaries printed to your terminal.

What should I see?

Each paper appears as a numbered entry like [1]. The title appears first, followed by the author, year, citation count, and available TLDR.

Some papers have no TLDR. Your assistant still shows their available metadata.

🙋‍♀️ No papers appearing?

  • Confirm that .env is beside literature_assistant.py if you see the GEMINI_API_KEY error.
  • Wait one minute before trying again if the helper reports 429 after its retries.
  • Confirm your internet connection if the request times out.

Help me debug why no papers appear.

You now have a working paper search tool that taps into 200 million academic papers. Next up, you'll connect Google Gemini to synthesize key findings across all the papers into a unified research briefing.

Synthesize Key Findings with Gemini

You can now search papers and see individual TLDRs for each one. That is helpful for scanning, but you still have to mentally piece together what the whole field is saying.

The real value of a research assistant is telling you what the literature says as a whole. In this step, you will connect to Google Gemini to read all the paper abstracts and synthesize unified key findings across the results.

In this step, get ready to:
  • Build a function that sends paper abstracts to Gemini for synthesis.
  • Add a post-search menu so you can trigger synthesis interactively.
  • Test the synthesis and see a multi-paragraph research briefing printed to your terminal.
Write the synthesis function

Your assistant already returns papers with TLDRs. Now you need a function that collects all the abstracts, sends them to Gemini in a single prompt, and prints a synthesized briefing.

  • In literature_assistant.py, find the end of the display_results function.
  • Add the following synthesize_findings function below it:
def synthesize_findings(papers, api_key):
    """Use Gemini to synthesize key findings across papers."""
    # Collect abstracts from papers that have them
    abstracts = []
    for paper in papers:
        abstract = paper.get("abstract")
        if abstract:
            title = paper.get("title", "Untitled")
            abstracts.append(f"Title: {title}\nAbstract: {abstract}")

    # Need at least 2 abstracts for a meaningful synthesis
    if len(abstracts) < 2:
        print("\nNot enough papers with abstracts to synthesize (need at least 2).")
        return

    # Build a prompt asking Gemini to extract themes
    prompt = (
        "You are a research assistant. Based on the following academic paper "
        "abstracts, identify 3-5 key findings and common themes across the "
        "papers. Write a concise research briefing that synthesizes what the "
        "field is saying overall.\n\n"
    )
    prompt += "\n\n---\n\n".join(abstracts)

    # Send to Gemini and print the response
    client = genai.Client(api_key=api_key)
    response = client.models.generate_content(
        model="gemini-2.5-flash",
        contents=prompt,
    )

    print(f"\n{'='*70}")
    print("KEY FINDINGS SYNTHESIS")
    print(f"{'='*70}\n")
    print(response.text)

What does this code do?

  • It loops through the papers and collects only those that have an abstract field. Not every paper in Semantic Scholar has a full abstract available.
  • If fewer than 2 papers have abstracts, it prints a message and exits early. A synthesis of one paper is just a summary, not a cross-paper analysis.
  • It builds a single prompt containing all the abstracts separated by --- dividers, asking Gemini to find 3-5 key findings and common themes.
  • It creates a genai.Client with your API key, calls generate_content() with the gemini-2.5-flash model, and prints the response text.
Why filter out papers without abstracts

Some Semantic Scholar papers contain only titles and metadata. Sending empty content to Gemini wastes tokens and weakens the synthesis, so the check passes only substantial text to the model.

Help me understand why some Semantic Scholar papers do not have abstracts.

Add a post-search menu

Right now, the script searches and quits. You need a menu loop that lets you trigger synthesis after viewing results.

  • In literature_assistant.py, scroll down to the main() function.
  • Find these lines at the end of main():
    query = input("\n  Enter research topic: ").strip()
    if query:
        print(f"\n  Searching for '{query}'...")
        papers = search_papers(query)
        display_results(papers)
  • Replace those lines with the following updated version that adds papers = [] and a post-search menu loop:
    papers = []

    query = input("\n  Enter research topic: ").strip()
    if query:
        print(f"\n  Searching for '{query}'...")
        papers = search_papers(query)
        display_results(papers)

        if papers:
            while True:
                print("  [s] Synthesize findings | [r] Related papers | [b] Back")
                sub = input("  > ").strip().lower()

                if sub == "b":
                    break

                elif sub == "s":
                    print("\n  Synthesizing key findings with Gemini...")
                    synthesize_findings(papers, api_key)

What changed in main?

  • papers = [] initializes the variable before the search so it is available to the menu loop.
  • After displaying results, a while True loop shows menu options. The learner types s to synthesize, r for related papers (wired in the next step), or b to exit the loop.
  • When the user picks s, the function calls synthesize_findings(papers, api_key) and prints the result.
  • Save literature_assistant.py.
Run the synthesis

Before you run it, think about what you expect to see. If your search returned 10 papers, do you think all of them will have abstracts for Gemini to read?

  • Run your script by executing this command:
python literature_assistant.py
  • Enter a research topic (try something broad like transformer attention).
  • After the paper list appears, type s and press Enter.

You should see a multi-paragraph synthesis printed under a KEY FINDINGS SYNTHESIS header, drawing themes from across the papers. This is the leap from individual summaries to a unified research briefing.

Seeing a 429 RESOURCE_EXHAUSTED error?

This means your Gemini API key has reached a request limit. Wait a few minutes before running the synthesis again.

If you see Not enough papers with abstracts to synthesize, search for a broader topic so more results include usable abstracts.

Help me troubleshoot the Gemini rate-limit error..

Help me find a topic with more abstracts..

✔️ Awesome, I've got everything!

Great. Double check you have saved literature_assistant.py.

ⓧ I'd like to double check the full code

import os
import time
import requests

from google import genai

def load_api_key():
    """Load environment values and return the Gemini API key."""
    # Find the .env file beside this script.
    env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
    if os.path.exists(env_path):
        # Copy each configured value into the process environment.
        with open(env_path) as f:
            for line in f:
                line = line.strip()
                if line and not line.startswith("#") and "=" in line:
                    key, value = line.split("=", 1)
                    os.environ[key.strip()] = value.strip()
    return os.environ.get("GEMINI_API_KEY")

def request_semantic_scholar(url, params, error_label):
    """Request Semantic Scholar data with optional authentication."""
    # Attach a personal key only when one exists.
    api_key = os.environ.get("SEMANTIC_SCHOLAR_API_KEY")
    headers = {"x-api-key": api_key} if api_key else {}

    # Retry rate-limit responses before giving up.
    for attempt in range(3):
        try:
            response = requests.get(url, params=params, headers=headers, timeout=15)
        except requests.RequestException as error:
            print(f"Error: {error_label} request failed: {error}")
            return {}

        if response.status_code == 200:
            return response.json()

        if response.status_code == 429 and attempt < 2:
            wait_seconds = 2 ** (attempt + 1)
            print(f"Rate limited. Retrying in {wait_seconds} seconds...")
            time.sleep(wait_seconds)
            continue

        print(f"Error: {error_label} returned status {response.status_code}")
        return {}

    return {}

def search_papers(query, filters=None):
    """Search Semantic Scholar for papers matching the query."""
    url = "https://api.semanticscholar.org/graph/v1/paper/search"
    # Request the metadata displayed later in the program.
    params = {
        "query": query,
        "fields": "title,authors,year,citationCount,abstract,tldr,url,paperId",
        "limit": 10,
    }

    # Reuse optional authentication, timeout, and retry handling.
    data = request_semantic_scholar(url, params, "Search API")
    return data.get("data", [])

def display_results(papers):
    """Display papers in a formatted list."""
    if not papers:
        print("\nNo papers found.")
        return

    print(f"\n{'='*70}")
    print(f"Found {len(papers)} papers:")
    print(f"{'='*70}\n")

    for i, paper in enumerate(papers, 1):
        title = paper.get("title", "Untitled")
        year = paper.get("year", "N/A")
        citations = paper.get("citationCount", 0)

        authors_list = paper.get("authors", [])
        if authors_list:
            first_author = authors_list[0].get("name", "Unknown")
            authors = f"{first_author} et al." if len(authors_list) > 1 else first_author
        else:
            authors = "Unknown"

        tldr = paper.get("tldr")
        tldr_text = tldr.get("text", "") if tldr else ""

        print(f"  [{i}] {title}")
        print(f"      {authors} | {year} | Cited by {citations}")
        if tldr_text:
            print(f"      TLDR: {tldr_text[:150]}...")
        print()

def synthesize_findings(papers, api_key):
    """Use Gemini to synthesize key findings across papers."""
    # Collect abstracts from papers that contain them.
    abstracts = []
    for paper in papers:
        abstract = paper.get("abstract")
        if abstract:
            title = paper.get("title", "Untitled")
            abstracts.append(f"Title: {title}\nAbstract: {abstract}")

    # Require enough source material for a cross-paper synthesis.
    if len(abstracts) < 2:
        print("\nNot enough papers with abstracts to synthesize (need at least 2).")
        return

    # Ask Gemini to identify findings and shared themes.
    prompt = (
        "You are a research assistant. Based on the following academic paper "
        "abstracts, identify 3-5 key findings and common themes across the "
        "papers. Write a concise research briefing that synthesizes what the "
        "field is saying overall.\n\n"
    )
    prompt += "\n\n---\n\n".join(abstracts)

    # Send the prompt to Gemini and print its response.
    client = genai.Client(api_key=api_key)
    response = client.models.generate_content(
        model="gemini-2.5-flash",
        contents=prompt,
    )

    print(f"\n{'='*70}")
    print("KEY FINDINGS SYNTHESIS")
    print(f"{'='*70}\n")
    print(response.text)

def main():
    """Run search and synthesis from the terminal."""
    api_key = load_api_key()
    if not api_key:
        print("Error: GEMINI_API_KEY not found. Create a .env file with your key.")
        return

    print("\n" + "=" * 70)
    print("  LITERATURE SEARCH ASSISTANT")
    print("=" * 70)
    papers = []

    query = input("\n  Enter research topic: ").strip()
    if query:
        print(f"\n  Searching for '{query}'...")
        papers = search_papers(query)
        display_results(papers)

        if papers:
            while True:
                print("  [s] Synthesize findings | [r] Related papers | [b] Back")
                sub = input("  > ").strip().lower()

                if sub == "b":
                    break
                elif sub == "s":
                    print("\n  Synthesizing key findings with Gemini...")
                    synthesize_findings(papers, api_key)

if __name__ == "__main__":
    main()

Your assistant now reads across multiple papers and tells you what the field is saying overall. Next up, you will add the ability to discover related papers that keyword search would never surface.

Discover Related Papers

Your assistant can already find papers by keyword and synthesize what the field is saying. But keyword search has a blind spot. It only surfaces papers that happen to use the exact words you typed.

Semantic Scholar maintains a citation graph connecting over 200 million papers. Its Recommendations API uses that graph (plus embeddings) to find related work you would never discover through keywords alone. In this step, you will add a paper discovery feature that lets the learner pick any result and explore what is connected to it.

In this step, get ready to:
  • Build a function that calls the Semantic Scholar Recommendations API.
  • Wire the recommendations into your interactive menu.
  • Test paper discovery to surface work that keyword search would miss.
Write the recommendations function

The Recommendations API lives at a different base URL from the search endpoint. It takes a single paper ID and returns papers that are related based on citation patterns and content similarity.

  • In literature_assistant.py, find the end of your synthesize_findings() function.
  • Add the following function below it:
def get_recommendations(paper_id):
    """Get recommended papers based on a seed paper."""
    url = f"https://api.semanticscholar.org/recommendations/v1/papers/forpaper/{paper_id}"
    # Request the fields needed by the existing display function.
    params = {
        "fields": "title,authors,year,citationCount,tldr,paperId",
        "limit": 5,
    }

    # Reuse optional authentication, timeout, and retry handling.
    data = request_semantic_scholar(url, params, "Recommendations API")
    return data.get("recommendedPapers", [])

What does this code do?

The function places paper_id in the Recommendations API path. It asks for five papers with the fields already supported by display_results().

Calling request_semantic_scholar() applies the same optional key, 15-second timeout, and 429 retry behavior used by search. Recommendations remain available when no personal key exists.

The endpoint stores its results under recommendedPapers. Returning an empty list gives the menu a safe fallback after a failed request.

  • Save literature_assistant.py.
  • Verify the function loads without errors by running this in your terminal:
python -c "from literature_assistant import get_recommendations; print('Function loaded!')"

You should see Function loaded! printed with no import errors.

Seeing an import error?

  • Make sure the new function is defined at the module level (same indentation as synthesize_findings), not accidentally nested inside another function.
  • Check for a missing closing parenthesis or quote in the function above it. Python will report the error on the wrong line.

Help me debug my import error in literature_assistant.py

Wire recommendations into the menu

Right now your main() function runs a single search and then shows the sub-menu. You need to restructure it into a persistent loop so the user can search multiple times, and also add the handler for the "r" option.

  • In literature_assistant.py, replace your entire main() function (from def main(): down to the last line before if __name__ == "__main__":) with the following code.
  • Start with the top-level loop that presents a main menu:
def main():
    api_key = load_api_key()
    if not api_key:
        print("Error: GEMINI_API_KEY not found. Create a .env file with your key.")
        return

    print("\n" + "=" * 70)
    print("  LITERATURE SEARCH ASSISTANT")
    print("=" * 70)

    papers = []

    while True:
        print("\n  [search] Search papers | [q] Quit")
        choice = input("\n  > ").strip().lower()

        if choice == "q":
            print("\n  Goodbye!")
            break

        elif choice == "search":
            query = input("\n  Enter research topic: ").strip()
            if not query:
                continue

            print(f"\n  Searching for '{query}'...")
            papers = search_papers(query)
            display_results(papers)

What changed in the main loop?

The old version ran one search and exited. The new version wraps everything in a while True loop with a top-level menu. The user can now search multiple topics in one session, or type q to quit cleanly.

  • Immediately below the display_results(papers) line you just wrote, add the sub-menu loop that handles synthesize, recommendations, and back:
            if papers:
                while True:
                    print("  [s] Synthesize findings | [r] Related papers | [b] Back")
                    sub = input("  > ").strip().lower()

                    if sub == "b":
                        break

                    elif sub == "s":
                        print("\n  Synthesizing key findings with Gemini...")
                        synthesize_findings(papers, api_key)

                    elif sub == "r":
                        num = input("  Enter paper number: ").strip()
                        if num.isdigit() and 1 <= int(num) <= len(papers):
                            paper = papers[int(num) - 1]
                            paper_id = paper.get("paperId")
                            if paper_id:
                                print(f"\n  Finding papers related to: {paper.get('title')}")
                                recs = get_recommendations(paper_id)
                                display_results(recs)
                            else:
                                print("  Could not get paper ID.")
                        else:
                            print(f"  Please enter a number between 1 and {len(papers)}.")

        else:
            print("  Type 'search' or 'q'.")

What does the 'r' handler do?

  • It prompts the user for a paper number from the displayed results.
  • It validates the input is a valid number within range.
  • It looks up the selected paper's paperId and passes it to get_recommendations().
  • It reuses display_results() to show recommendations in the same format as search results.
  • Make sure the if __name__ == "__main__": block is still at the very bottom of your file, below the new main() function.
  • Save literature_assistant.py.

Getting an indentation error?

  • The sub-menu code (starting with if papers:) must be indented 3 levels deep: inside while True > inside elif choice == "search" > inside def main().
  • The final else: block should be at the same level as if choice == "q": and elif choice == "search": (two levels deep).

Help me fix my indentation in the main() function

Test paper discovery

Time to see the Recommendations API in action. The papers it returns should feel related to your selection, but cover angles and methodologies that keyword search would never surface.

  • Run your assistant by executing:
python literature_assistant.py
  • Type search and enter a research topic (for example, transformer attention).
  • Once results appear, type r at the sub-menu prompt.
  • Enter the number of a paper that looks interesting.

Before you press Enter on the paper number, take a guess: will the recommended papers be on the exact same topic as your keyword, or will they branch into adjacent areas?

You should see 5 recommended papers printed in the same format as your search results. Notice how some share the same domain but approach it from a different angle. That is the citation graph at work.

Why do recommendations look different from search results?

Keyword search matches your words against paper titles and abstracts. Recommendations follow the citation graph through citations, shared references, and similar content embeddings.

This surfaces adjacent work, including alternative methods, applications, and theoretical foundations that a keyword search can miss.

🙋‍♀️ Recommendations not appearing?

  • Wait one minute before choosing r again if the helper reports 429 after retrying.
  • Confirm your internet connection if the request times out.
  • Select a different paper if you see Could not get paper ID. Confirm that search_papers() requests paperId if every result has the same problem.

Help me debug why paper recommendations are not working.

✔️ Awesome, I've got everything!

Great. Double-check that you've saved literature_assistant.py with the recommendations function and updated menu.

ⓧ I'd like to double check the full code

Part 1: Helpers

import os
import time
import requests

from google import genai


def load_api_key():
    """Load environment values and return the Gemini API key."""
    # Find the .env file beside this script.
    env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
    if os.path.exists(env_path):
        # Copy each configured value into the process environment.
        with open(env_path) as f:
            for line in f:
                line = line.strip()
                if line and not line.startswith("#") and "=" in line:
                    key, value = line.split("=", 1)
                    os.environ[key.strip()] = value.strip()
    return os.environ.get("GEMINI_API_KEY")


def request_semantic_scholar(url, params, error_label):
    """Request Semantic Scholar data with optional authentication."""
    # Attach a personal key only when one exists.
    api_key = os.environ.get("SEMANTIC_SCHOLAR_API_KEY")
    headers = {"x-api-key": api_key} if api_key else {}

    # Retry rate-limit responses before giving up.
    for attempt in range(3):
        try:
            response = requests.get(url, params=params, headers=headers, timeout=15)
        except requests.RequestException as error:
            print(f"Error: {error_label} request failed: {error}")
            return {}

        if response.status_code == 200:
            return response.json()

        if response.status_code == 429 and attempt < 2:
            wait_seconds = 2 ** (attempt + 1)
            print(f"Rate limited. Retrying in {wait_seconds} seconds...")
            time.sleep(wait_seconds)
            continue

        print(f"Error: {error_label} returned status {response.status_code}")
        return {}

    return {}


def search_papers(query, filters=None):
    """Search Semantic Scholar for papers matching the query."""
    url = "https://api.semanticscholar.org/graph/v1/paper/search"
    # Request the metadata displayed later in the program.
    params = {
        "query": query,
        "fields": "title,authors,year,citationCount,abstract,tldr,url,paperId",
        "limit": 10,
    }

    # Reuse optional authentication, timeout, and retry handling.
    data = request_semantic_scholar(url, params, "Search API")
    return data.get("data", [])


def display_results(papers):
    """Display papers in a readable format."""
    if not papers:
        print("\nNo papers found.")
        return

    print(f"\n{'='*70}")
    print(f"FOUND {len(papers)} PAPERS")
    print(f"{'='*70}")

    for i, paper in enumerate(papers, 1):
        title = paper.get("title", "Untitled")
        authors = paper.get("authors", [])
        author_names = ", ".join(a.get("name", "") for a in authors[:3])
        if len(authors) > 3:
            author_names += " et al."
        year = paper.get("year", "Unknown")
        citations = paper.get("citationCount", 0)

        print(f"\n[{i}] {title}")
        print(f"    Authors: {author_names or 'Unknown'}")
        print(f"    Year: {year} | Citations: {citations}")

        # Show the API-provided short summary when available.
        tldr = paper.get("tldr")
        if tldr and tldr.get("text"):
            print(f"    TLDR: {tldr['text']}")


def synthesize_findings(papers, api_key):
    """Use Gemini to synthesize key findings across papers."""
    # Collect abstracts from papers that have them.
    abstracts = []
    for paper in papers:
        abstract = paper.get("abstract")
        if abstract:
            title = paper.get("title", "Untitled")
            abstracts.append(f"Title: {title}\nAbstract: {abstract}")

    # Require enough source material for a cross-paper synthesis.
    if len(abstracts) < 2:
        print("\nNot enough papers with abstracts to synthesize (need at least 2).")
        return

    # Ask Gemini to identify findings and shared themes.
    prompt = (
        "You are a research assistant. Based on the following academic paper "
        "abstracts, identify 3-5 key findings and common themes across the "
        "papers. Write a concise research briefing that synthesizes what the "
        "field is saying overall.\n\n"
    )
    prompt += "\n\n---\n\n".join(abstracts)

    # Send the prompt to Gemini and print its response.
    client = genai.Client(api_key=api_key)
    response = client.models.generate_content(
        model="gemini-2.5-flash",
        contents=prompt,
    )

    print(f"\n{'='*70}")
    print("KEY FINDINGS SYNTHESIS")
    print(f"{'='*70}\n")
    print(response.text)


def get_recommendations(paper_id):
    """Get recommended papers based on a seed paper."""
    url = f"https://api.semanticscholar.org/recommendations/v1/papers/forpaper/{paper_id}"
    # Request the fields needed by the existing display function.
    params = {
        "fields": "title,authors,year,citationCount,tldr,paperId",
        "limit": 5,
    }

    # Reuse optional authentication, timeout, and retry handling.
    data = request_semantic_scholar(url, params, "Recommendations API")
    return data.get("recommendedPapers", [])

Part 2: Main Loop

def main():
    """Run search, synthesis, and discovery from the terminal."""
    api_key = load_api_key()
    if not api_key:
        print("Error: GEMINI_API_KEY not found. Create a .env file with your key.")
        return

    print("\n" + "=" * 70)
    print("  LITERATURE SEARCH ASSISTANT")
    print("=" * 70)

    papers = []

    while True:
        print("\n  [search] Search papers | [q] Quit")
        choice = input("\n  > ").strip().lower()

        if choice == "q":
            print("\n  Goodbye!")
            break

        elif choice == "search":
            query = input("\n  Enter research topic: ").strip()
            if not query:
                continue

            print(f"\n  Searching for '{query}'...")
            papers = search_papers(query)
            display_results(papers)

            if papers:
                while True:
                    print("  [s] Synthesize findings | [r] Related papers | [b] Back")
                    sub = input("  > ").strip().lower()

                    if sub == "b":
                        break

                    elif sub == "s":
                        print("\n  Synthesizing key findings with Gemini...")
                        synthesize_findings(papers, api_key)

                    elif sub == "r":
                        num = input("  Enter paper number: ").strip()
                        if num.isdigit() and 1 <= int(num) <= len(papers):
                            paper = papers[int(num) - 1]
                            paper_id = paper.get("paperId")
                            if paper_id:
                                print(f"\n  Finding papers related to: {paper.get('title')}")
                                recs = get_recommendations(paper_id)
                                display_results(recs)
                            else:
                                print("  Could not get paper ID.")
                        else:
                            print(f"  Please enter a number between 1 and {len(papers)}.")

        else:
            print("  Type 'search' or 'q'.")


if __name__ == "__main__":
    main()

Your literature assistant now has all three core features: search, synthesis, and discovery. Next, you will tackle the Secret Mission and add filters so you can narrow results like a professional researcher.

Secret mission

Add Search Filters

Real researchers rarely search without constraints. They filter by year to find recent work, by citation count to find high-impact papers, and by open access to find papers they can actually read. Can you add these filters to your assistant?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with free APIs, so there are no ongoing costs.

Resources you used:

  • A Gemini API key (free tier, no charges for the usage in this project).
  • Local Python files (literature_assistant.py and .env).
  • Installed pip packages (requests and google-genai).

Keep everything running

No action needed. Choose this if you plan to keep using your literature search assistant.

  • Your tool is ready to use anytime by running python literature_assistant.py from the folder containing your project files.
  • The Gemini free tier has rate limits but is sufficient for personal research use.
  • The Semantic Scholar API requires no authentication and has no usage charges.

Pause - I'll come back to this later

Since everything runs locally and uses free APIs, there is nothing to pause. Your files stay on your machine and cost nothing while idle.

  • Close VS Code to free up memory.
  • When you come back, reopen the folder containing literature_assistant.py in VS Code and run the script again.

Delete - I don't want to use this again

Remove all project resources and revoke API access.

  • Revoke your Gemini API key by visiting https://aistudio.google.com/app/apikey. Find your key in the list, click the trash icon next to it, and confirm deletion.
  • Delete the project files. On macOS/Linux, open a terminal and run the command below. On Windows, use File Explorer to delete the folder containing your project.
  • Uninstall the pip packages by running the command below.
  • Delete the project folder on macOS or Linux by running this command:
rm -rf ~/Desktop/literature-search-assistant
  • Delete the project folder on Windows by running this command:
Remove-Item -Recurse -Force ~\Desktop\literature-search-assistant
  • Uninstall the project packages by running this command:
pip uninstall requests google-genai -y

Nice Work!

Nice Work!

You did it! You built a literature search assistant that searches more than 200 million academic papers, creates briefings with Google Gemini, and discovers related work through citation-graph recommendations.

You've learned how to:

  • Called the Semantic Scholar API to search academic papers by topic, then parsed JSON responses into formatted results with titles, authors, citation counts, and TLDR summaries.
  • Integrated Google Gemini to synthesize key findings across multiple paper abstracts into a unified research briefing.
  • Used the Recommendations API to discover related papers beyond keyword search, surfacing work you would never find manually.
  • Secret Mission: Added year range, minimum citation, and open access filters using conditional query parameters to narrow results like a pro researcher.

Ready to quiz yourself?