ResearchGap: Map Research to Prior Work

Build a local AI dashboard to map research ideas to evidence-backed prior work.

Introduction

30 Second Summary

A promising research idea can feel original until you start comparing it with years of published work. The difficult part is seeing which pieces already appear in earlier studies.

In this project, you will build a local Streamlit dashboard that maps a research idea against relevance-ranked papers from the Semantic Scholar Academic Graph API. It uses an IBM Granite model through Ollama to produce a quote-validated evidence map with sample-scoped gap signals.

What You'll Build

When an analysis finishes, you see four scientific components flow into a ranked five-paper evidence map that keeps every gap signal tied to the retrieved sample.

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

  • A masked research form where you can submit an idea using your existing API key without storing the credential in source code.
  • A structured literature view that displays exactly four searchable components before a relevance-ranked list of five abstract-bearing papers.
  • A quote-validated evidence matrix with sample-scoped gap signals that keep source evidence separate from tentative interpretation.
  • Secret Mission: Add a one-click CSV export that preserves the six validated matrix columns for review outside the app.

Are there any prerequisites?

You need a Windows PC running Windows 10 22H2 or newer. The project also requires Python 3.11 through 3.14, Visual Studio Code, an existing Semantic Scholar API key, internet access, and at least 6.1 GB of free disk space.

Before We Start

Before We Start...

Before any hands-on work begins, this is your moment to define the research idea you want to test. You will also name the decision that a prior-work evidence map should help you make.

Set Up the Local Research Stack

Your research goal is clear. ResearchGap still needs a dependable local workspace before research data can flow.

An isolated Python environment keeps this project's packages contained. Ollama supplies the local model endpoint used by later steps.

In this step, get ready to:
  • Confirm that Windows, disk space, and Python meet the project requirements.
  • Create an isolated environment with the pinned project dependencies.
  • Verify the local stack with a browser demo and a model response.
Confirm your Windows and Python setup

Ollama needs at least 4 GB for its Windows installation. The Granite model adds approximately 2.1 GB before Python package overhead.

  • Select the Search box on the Windows taskbar.
  • Type winver.
  • Select winver from the results.
  • Confirm that the dialog shows Windows 10 version 22H2 or a newer Windows release.
  • Select File Explorer from the Windows taskbar.
  • Select This PC in the left sidebar.
  • Confirm that the drive containing your Desktop has at least 6.1 GB available under Devices and drives.

Need a newer Windows release or more space?

  • Update Windows before continuing if your version is older than Windows 10 22H2.
  • Free enough space on the Desktop drive to reach at least 6.1 GB.

Help me prepare Windows for ResearchGap.

Once those checks pass, your machine has the baseline capacity for the local model.

  • Press the Windows key to open Search.
  • Type Visual Studio Code.
  • Press Enter.
  • Select File from the top menu.
  • Select Open Folder.
  • Select your Desktop.
  • Select New Folder.
  • Enter researchgap as the folder name.

Why use one project folder?

The researchgap folder keeps the environment and source files together. Visual Studio Code also starts its terminal inside the folder you open.

  • Select Select Folder.
  • Select Yes, I trust the authors if the Workspace Trust dialog appears.
  • Select View from the top menu.
  • Select Terminal.
  • Choose PowerShell from the terminal profile list if another shell opens.
  • Check the installed Python version by running this command:
python --version

What should I see?

The command prints the Python version used by this terminal. ResearchGap supports Python 3.11 through 3.14.

✔️ I see a supported version

Your Python runtime is ready for the project environment.

ⓧ I see an older version

The current Python runtime falls below the project's supported range. Install a supported release before creating the environment.

  • Open the official Python downloads page.
  • Install a Python release from 3.11 through 3.14.
  • Allow the installer to make the Python command available through PATH when prompted.
  • Return to Visual Studio Code.
  • Create a fresh PowerShell terminal.
  • Check the version again by running:
python --version

Confirm the upgrade

The new terminal should report Python 3.11 through 3.14.

ⓧ Command not found

Windows cannot currently find a Python runtime. Install one from the official Windows downloads page.

  • Open the official Python downloads page.
  • Install a Python release from 3.11 through 3.14.
  • Allow the installer to make the Python command available through PATH when prompted.
  • Return to Visual Studio Code.
  • Create a fresh PowerShell terminal.
  • Check the installation by running:
python --version

Confirm the installation

The fresh terminal should print a Python version from 3.11 through 3.14.

Still seeing the wrong Python version?

Existing terminal sessions keep the environment they started with. Selecting an interpreter also affects new terminals, so both parts need a refresh.

  • Close the active PowerShell terminal by selecting the trash-can button in the Terminal panel.
  • Press Ctrl+Shift+P to open the Command Palette.
  • Select Python: Select Interpreter.
  • Choose the installed Python interpreter that reports version 3.11 through 3.14.
  • Select Terminal from the top menu.
  • Select New Terminal.
  • Confirm the interpreter used by the fresh terminal by running:
python --version

You should see Python 3.11 through 3.14. If the old version remains, ask for help checking the selected interpreter and terminal environment.

A virtual environment gives ResearchGap its own package location. Activating it keeps later installations inside .venv.

  • Create and activate .venv by running these commands:
python -m venv .venv
.venv\Scripts\Activate.ps1

What do these commands do?

  • The first command creates an isolated Python environment inside .venv.
  • The second command activates that environment in the current PowerShell terminal.
  • Confirm that the terminal prompt begins with (.venv).
  • Confirm that the Visual Studio Code Explorer shows the .venv folder inside researchgap.

Activation blocked in PowerShell?

A PowerShell security policy can prevent the activation script from running. Check whether your organization manages script execution before changing any policy.

Help me activate this virtual environment safely.

Create the requirements file and test Streamlit

A pinned dependency file gives every installation the same versions. This project uses Streamlit 1.63.0 for the local interface and Requests 2.34.2 for HTTP calls.

  • Select the new-file button beside researchgap in the Explorer sidebar.
  • Enter requirements.txt as the file name.
  • Fill requirements.txt with the two pinned dependencies by pasting:
streamlit==1.63.0
requests==2.34.2

What does this file control?

  • The streamlit pin fixes the dashboard framework at version 1.63.0.
  • The requests pin fixes the HTTP client at version 2.34.2.
  • Save requirements.txt.
  • Confirm that requirements.txt appears beside .venv in the Explorer sidebar.

✔️ Awesome, I've got everything!

Your pinned dependency file is saved and ready to install.

ⓧ I'd like to double check the full code

streamlit==1.63.0
requests==2.34.2
  • Install the pinned packages into the active environment by running:
python -m pip install -r requirements.txt

What does this command do?

Python runs the copy of pip attached to the active environment. Pip reads both version pins from requirements.txt.

  • Wait for the package installation to finish.

You should return to the active PowerShell prompt without an installation failure.

Package installation failed?

  • Confirm that the terminal prompt still begins with (.venv).
  • Confirm that requirements.txt contains both pins exactly as shown above.
  • Check that your internet connection can reach the package registry.

Help me diagnose this package installation.

Before you launch the demo, what do you expect the browser result to prove about the active environment?

  • Launch the Streamlit installation check by running:
streamlit hello

What does this check prove?

Streamlit starts its Hello app through the active virtual environment. A successful browser page confirms that the pinned framework is installed and runnable.

Your browser should open the Streamlit Hello app. The terminal remains occupied while the local demo is running.

Browser demo did not open?

  • Confirm that the terminal prompt showed (.venv) before you started Streamlit.
  • Check the terminal for a local URL if your browser did not open automatically.

Help me troubleshoot the Streamlit Hello app.

  • Return to the PowerShell terminal in Visual Studio Code.
  • Press Ctrl+C to stop the Streamlit demo.
Install Ollama and test Granite

The later analysis steps send structured prompts to a model running on your own computer. IBM Granite uses the local model identifier ibm/granite4.1:3b.

Why Ollama for this app?

LM Studio also supports JSON Schema output. Ollama fits this project because its Windows app runs in the background and exposes the local API automatically.

  • Open the official Ollama Windows download page.
  • Download OllamaSetup.exe.
  • Run the downloaded OllamaSetup.exe file.
  • Complete the installation using the default location.
  • Return to Visual Studio Code from earlier.
  • Close the current terminal using its trash-can button.
  • Select Terminal from the top menu.
  • Select New Terminal.

The installer starts Ollama in the background. The fresh terminal can now access its command-line tool.

  • Verify that the fresh PowerShell terminal can find Ollama by running:
ollama -v

You should see the installed Ollama version. This confirms that the refreshed terminal can find the command before the model download begins.

Ollama command not recognized?

If PowerShell says the term is not recognized, the terminal likely started before the installer updated your user PATH.

  • Close the current terminal using the trash-can button in the Terminal panel.
  • Select Terminal from the top menu.
  • Select New Terminal.
  • Check the command again by running:
ollama -v

If PowerShell still cannot find Ollama, ask for help checking the Ollama installation and user PATH.

The first model run downloads 2.1 GB, so this is the longest wait in the step. Before you run the final check, do you expect Granite to answer locally after that download finishes?

  • Download and start the Granite model by running:
ollama run ibm/granite4.1:3b

What does this command do?

Ollama downloads the ibm/granite4.1:3b model on its first run. It then opens an interactive local chat session using the downloaded model.

  • Leave the terminal open while the download progress moves.
  • Wait for the interactive prompt to appear.
  • Enter Why should supporting evidence use exact quotes?.
  • Press Enter.

You should see Granite return a local text response beneath your prompt. That response confirms that Ollama and the downloaded model are ready for ResearchGap.

Granite did not return a response?

  • Confirm that the model download reached completion before entering your prompt.
  • Confirm that Ollama is still running in the Windows background.
  • Create another fresh PowerShell terminal if the Ollama command is unavailable.

Help me troubleshoot my local Granite model.

You've locked in the local foundation: the isolated environment runs Streamlit and Granite responds through Ollama. Next, you'll turn your research idea into a visible list of nearby papers.

Retrieve the Closest Papers

Your local Python stack is ready. The IBM Granite 4.1 3B model also responds on your machine.

ResearchGap now needs its first path from a research idea to real scientific papers. A relevance-ranked search gives you an immediate set of nearby studies.

You’ll build a Streamlit form for the idea. The app will use the Semantic Scholar Academic Graph API to show up to five papers that contain abstracts.

In this step, get ready to:
  • Create a form for a research idea and a masked API key.
  • Retrieve relevance-ranked papers that contain abstracts.
  • Display each paper’s title, authors, year, and source URL.
Create the research form

The form keeps both inputs together until you submit them. Its password-style field masks your API key while you type.

Your API key remains a runtime input. The provided source code never contains the key.

  • In the Explorer sidebar in Visual Studio Code, click the New File icon beside the researchgap folder.
  • Enter app.py as the file name.

You’ll see app.py listed beside requirements.txt in the Explorer sidebar.

  • Add the form to app.py by pasting this code:
import time
import requests
import streamlit as st

def main():
    # Render the form that collects runtime-only research inputs.
    st.markdown("# ResearchGap")
    st.write(
        "Turn a research idea into components, nearby papers, a validated evidence "
        "matrix, and cautious gap signals."
    )

    with st.form("research_form"):
        idea = st.text_area(
            "Research idea",
            height=180,
            placeholder="Describe the population, method, context, and outcome.",
        )
        api_key = st.text_input("Semantic Scholar API key", type="password")
        submitted = st.form_submit_button("Analyze research idea", type="primary")

    if not submitted:
        return

    if not idea.strip() or not api_key.strip():
        st.error("Enter both a research idea and a Semantic Scholar API key.")
        return

if __name__ == "__main__":
    main()

What does this code do?

  • The research_form form groups the research idea with the API key.
  • The idea text area gives you space for a detailed research proposal.
  • The type="password" setting masks the API key on screen.
  • The validation message stops an empty form from sending a request.
  • Save app.py.
  • Return to the PowerShell terminal from the previous step.
  • Start the form by running this command:
streamlit run app.py

What should I see?

Streamlit starts the local app and opens it in your browser. You’ll see a large research-idea field with a masked API-key field underneath.

Your first ResearchGap screen is live. The masked field gives your existing API key a safer route into the request.

Form not opening?

  • Confirm that PowerShell still shows the activated .venv environment.
  • Confirm that app.py sits inside the researchgap folder.
  • Check the terminal for a Python syntax error that points to an incomplete line in the pasted form.

Help me diagnose why my local Streamlit form is not opening.

  • Return to the PowerShell terminal.
  • Press Ctrl+C to stop the temporary server.
Connect the relevance search

A REST API turns your form input into an HTTP request. Requests sends that request with explicit query parameters and authentication headers.

The search asks for ten results before filtering out papers without abstracts. Keeping the first five remaining papers preserves the API’s relevance order.

  • In app.py, place the following code below the two import lines:
SEMANTIC_SCHOLAR_SEARCH_URL = "https://api.semanticscholar.org/graph/v1/paper/search"
PAPER_FIELDS = "paperId,title,abstract,year,authors,url"

def search_papers(query, api_key):
    # Normalize the runtime key before placing it in the authentication header.
    clean_key = api_key.strip().strip("\"'")
    # Retry rate-limited requests after one second, then two seconds.
    for attempt in range(3):
        response = requests.get(
            SEMANTIC_SCHOLAR_SEARCH_URL,
            params={"query": query, "limit": 10, "fields": PAPER_FIELDS},
            headers={"x-api-key": clean_key},
            timeout=30,
        )
        if response.status_code != 429:
            response.raise_for_status()
            break
        if attempt == 2:
            response.raise_for_status()
        time.sleep(2 ** attempt)
    payload = response.json()
    papers = [paper for paper in payload.get("data", []) if paper.get("abstract")][:5]
    if not papers:
        raise ValueError("Semantic Scholar returned no papers with abstracts.")
    return papers

How does the search stay focused?

  • The relevance endpoint ranks papers against the plain-text research idea.
  • The clean_key value removes surrounding whitespace and accidental outer quote characters before authentication.
  • A 429 response pauses for one second, then two seconds, before the final failure reaches the app. Semantic Scholar starts individual API keys at one request per second.
  • The abstract filter removes results that cannot support evidence analysis later.
Render and test the paper list

The search function returns paper dictionaries in relevance order. The page now needs to turn each dictionary into a readable result.

  • In main(), find the input validation block that ends with the second return statement.
  • Place the following code immediately after that validation block:
    try:
        papers = search_papers(idea.strip(), api_key.strip())
        st.markdown("## Closest prior work in this sample")
        for index, paper in enumerate(papers, start=1):
            authors = ", ".join(
                author.get("name", "Unknown") for author in paper.get("authors", [])
            )
            st.write(index, paper.get("title", "Untitled"))
            st.write(f"{authors or 'Authors unavailable'} | {paper.get('year') or 'Year unavailable'}")
            if paper.get("url"):
                st.write(paper["url"])
    except requests.RequestException as error:
        st.error(f"API request failed: {error}")
    except ValueError as error:
        st.error(f"Analysis failed: {error}")

What does the result loop do?

  • The call strips extra whitespace from both form values before searching.
  • The loop displays papers in the order returned by Semantic Scholar.
  • The author expression joins the available names into one readable line.
  • The exception handlers turn request failures and empty result sets into visible app errors.
  • Save app.py.
  • Return to the PowerShell terminal.
  • Start the completed search-only app by running this command:
streamlit run app.py

What does this command prove?

The command loads your saved app.py through Streamlit. A successful launch proves that the form and search code can load together.

  • In the Research idea field, enter your research idea.
  • Paste your existing API key into the Semantic Scholar API key field.

Before you submit the form, do you think a ranked paper list can show which parts of your research idea each paper supports?

  • Click Analyze research idea.

You’ll see up to five abstract-bearing papers in relevance order. Each result shows its title, authors, year, and Semantic Scholar URL.

That is a substantial first win: your research idea now produces a source-linked reading list.

  • Compare the paper list with the separate population, method, context, and outcome signals inside your idea.

The page gives you useful nearby papers. However, it provides no component-by-component view of their overlap.

That shortfall is intentional. A ranked list alone cannot show which parts of the idea are established or missing.

Search request not succeeding?

  • Let the automatic one-second and two-second retry pauses finish before submitting another request after a 429 response.
  • Paste the complete API key without surrounding quote characters.
  • Replace the key if the app reports an authentication failure. A valid key belongs in the case-sensitive x-api-key header.
  • Try a shorter plain-text research idea if the search returns no abstract-bearing papers.

Help me troubleshoot my Semantic Scholar paper request.

✔️ Awesome, I've got everything!

Your saved app.py now matches the search-only version of ResearchGap.

ⓧ I'd like to double check the full app.py

import time

import requests
import streamlit as st

SEMANTIC_SCHOLAR_SEARCH_URL = "https://api.semanticscholar.org/graph/v1/paper/search"
PAPER_FIELDS = "paperId,title,abstract,year,authors,url"


def search_papers(query, api_key):
    # Normalize the runtime key before placing it in the authentication header.
    clean_key = api_key.strip().strip("\"'")
    # Retry rate-limited requests after one second, then two seconds.
    for attempt in range(3):
        response = requests.get(
            SEMANTIC_SCHOLAR_SEARCH_URL,
            params={"query": query, "limit": 10, "fields": PAPER_FIELDS},
            headers={"x-api-key": clean_key},
            timeout=30,
        )
        if response.status_code != 429:
            response.raise_for_status()
            break
        if attempt == 2:
            response.raise_for_status()
        time.sleep(2 ** attempt)
    payload = response.json()
    papers = [paper for paper in payload.get("data", []) if paper.get("abstract")][:5]
    if not papers:
        raise ValueError("Semantic Scholar returned no papers with abstracts.")
    return papers


def main():
    # Render the form that collects runtime-only research inputs.
    st.markdown("# ResearchGap")
    st.write(
        "Turn a research idea into components, nearby papers, a validated evidence "
        "matrix, and cautious gap signals."
    )

    with st.form("research_form"):
        idea = st.text_area(
            "Research idea",
            height=180,
            placeholder="Describe the population, method, context, and outcome.",
        )
        api_key = st.text_input("Semantic Scholar API key", type="password")
        submitted = st.form_submit_button("Analyze research idea", type="primary")

    if not submitted:
        return

    if not idea.strip() or not api_key.strip():
        st.error("Enter both a research idea and a Semantic Scholar API key.")
        return

    try:
        papers = search_papers(idea.strip(), api_key)
        st.markdown("## Closest prior work in this sample")
        for index, paper in enumerate(papers, start=1):
            authors = ", ".join(
                author.get("name", "Unknown") for author in paper.get("authors", [])
            )
            st.write(index, paper.get("title", "Untitled"))
            st.write(
                f"{authors or 'Authors unavailable'} | "
                f"{paper.get('year') or 'Year unavailable'}"
            )
            if paper.get("url"):
                st.write(paper["url"])
    except requests.RequestException as error:
        st.error(f"API request failed: {error}")
    except ValueError as error:
        st.error(f"Analysis failed: {error}")


if __name__ == "__main__":
    main()

Your app can now turn an idea into a relevance-ranked reading list. Next, you’ll split that idea into four scientific components so each paper can be compared against a clearer map.

Decompose the Research Idea

Your Streamlit app already turns one research idea into a ranked paper list from the Semantic Scholar Academic Graph API. That list still treats the idea as one block.

A whole-idea search can hide separate signals for population, method, context, or outcome. In this step, Ollama produces a reusable four-part map with JSON Schema structured output before retrieval begins.

In this step, get ready to:
  • Configure a schema-constrained request to the local model.
  • Validate four components plus a concise search query.
  • Display the components before retrieving the paper list.
Define the structured model request

A JSON Schema defines the exact shape that the local model must return. The IBM Granite model must produce one query plus exactly four component objects.

  • In app.py, replace the import and constant block at the top with this code:
import json
import time

import requests
import streamlit as st

# Keep external endpoints and response fields in one global configuration block.
SEMANTIC_SCHOLAR_SEARCH_URL = "https://api.semanticscholar.org/graph/v1/paper/search"
OLLAMA_CHAT_URL = "http://localhost:11434/api/chat"
MODEL_NAME = "ibm/granite4.1:3b"
PAPER_FIELDS = "paperId,title,abstract,year,authors,url"

What does this configuration do?

  • The json module parses the model's structured response.
  • The time module keeps the Semantic Scholar retry pauses available after this import block is replaced.
  • The OLLAMA_CHAT_URL value points requests to the local chat endpoint.
  • The MODEL_NAME value selects the local Granite model.
  • The PAPER_FIELDS value keeps the existing Semantic Scholar response fields unchanged.
  • Below PAPER_FIELDS, add the decomposition schema:
DECOMPOSITION_SCHEMA = {
    "type": "object",
    "properties": {
        "search_query": {"type": "string"},
        "components": {
            "type": "array",
            "minItems": 4,
            "maxItems": 4,
            "items": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "description": {"type": "string"},
                },
                "required": ["name", "description"],
                "additionalProperties": False,
            },
        },
    },
    "required": ["search_query", "components"],
    "additionalProperties": False,
}

How does the schema constrain the response?

  • The top-level object must contain search_query plus components.
  • The minItems value requires at least four components.
  • The maxItems value limits the response to four components.
  • Each component must contain a name plus a description.
  • Save app.py.
  • Confirm the new configuration still loads by running this command:
streamlit run app.py

App failing after the schema change?

Your browser should show the existing ResearchGap form with its research-idea field. This confirms the schema has valid Python syntax.

If the app fails to load, check the schema structure below.

Check that every opening brace in DECOMPOSITION_SCHEMA has a matching closing brace. Make sure the schema sits above your function definitions.

Ask for help with the schema error.

  • Stop the running Streamlit process in its PowerShell window.
Build the decomposition helpers

The Ollama REST API accepts chat messages through POST /api/chat. Your helper sends the schema in format so the response can be parsed as structured data.

  • Above search_papers(), add the local JSON request helper:
def ollama_json(messages, schema):
    response = requests.post(
        OLLAMA_CHAT_URL,
        json={
            "model": MODEL_NAME,
            "messages": messages,
            "stream": False,
            "format": schema,
            "options": {"temperature": 0},
        },
        timeout=180,
    )
    response.raise_for_status()
    content = response.json()["message"]["content"]
    return json.loads(content)

What does this helper do?

  • The request sends the selected model plus the supplied messages to the local endpoint.
  • The stream value requests one complete response.
  • The schema in format constrains the response shape.
  • The temperature value keeps the structured extraction deterministic.
  • The final line converts the model's JSON text into a Python object.
  • Save app.py.
  • Check that the request helper has valid syntax by running the app again:
streamlit run app.py

Error before the form loads?

You should see the ResearchGap form load without an app error. The new helper is now available to the application.

If the form does not load, check the helper placement and indentation below.

Check that ollama_json() sits above search_papers(). Confirm that every line inside the function uses four spaces of indentation.

Ask for help with the helper syntax.

  • Stop the running Streamlit process in its PowerShell window.
  • Below ollama_json(), add the model request portion of decompose_idea():
def decompose_idea(idea):
    result = ollama_json(
        [
            {
                "role": "system",
                "content": (
                    "You decompose research ideas for literature search. "
                    "Return exactly four distinct scientific components and one "
                    "concise plain-text search query. Components should cover the "
                    "population or system, method or intervention, context or data, "
                    "and intended outcome when those concepts are present."
                ),
            },
            {"role": "user", "content": idea},
        ],
        DECOMPOSITION_SCHEMA,
    )

How does the decomposition prompt work?

The system message defines the extraction task. The user message supplies the learner's research idea without adding it to the source code.

Passing DECOMPOSITION_SCHEMA gives the model the exact response structure required by the app.

  • Inside decompose_idea(), add this validation directly below the closing parenthesis:
    components = result.get("components", [])
    search_query = result.get("search_query", "").strip()
    if len(components) != 4 or not search_query:
        raise ValueError("The local model returned an incomplete decomposition.")

    cleaned_components = []
    for component in components:
        name = str(component.get("name", "")).strip()
        description = str(component.get("description", "")).strip()
        if not name or not description:
            raise ValueError("A research component was missing a name or description.")
        cleaned_components.append({"name": name, "description": description})

    return {
        "search_query": search_query.replace("-", " "),
        "components": cleaned_components,
    }

Why validate the model output again?

  • The first check requires four components plus a non-empty query.
  • The loop removes surrounding whitespace from every component value.
  • The second check rejects a component with a missing name or description.
  • The return value replaces query hyphens with spaces because the relevance endpoint expects plain-text terms.
  • Save app.py.
  • Confirm the completed decomposition function has valid syntax by running:
streamlit run app.py

Function causing a syntax error?

You should see the ResearchGap form load again. The decomposition function is complete without changing the form itself.

If the function triggers a syntax error, check its indentation below.

Make sure the validation lines remain inside decompose_idea(). Each line should align beneath the earlier result assignment.

Ask for help with the function structure.

  • Stop the running Streamlit process in its PowerShell window.
Use the generated query before retrieval

The decomposition becomes useful when it controls retrieval. The app now asks the local model for four components before sending the cleaned query to Semantic Scholar.

  • Inside main(), find the except block that currently handles ValueError.
  • Replace that exception block with this broader analysis handler:
    except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
        st.error(f"Analysis failed: {error}")

What does the broader handler cover?

The handler makes malformed JSON plus unexpected response shapes visible in the app. Validation failures also remain visible through the same error message.

  • Inside the spinner block, find the line that passes idea.strip() directly to search_papers().
  • Replace that line with these two lines:
            decomposition = decompose_idea(idea.strip())
            papers = search_papers(decomposition["search_query"], api_key.strip())

How has the request flow changed?

The first line turns the original idea into structured components plus a concise query. The second line sends that generated query to Semantic Scholar.

  • Save app.py.
  • Start the updated request flow by running:
streamlit run app.py

What does this run test?

The app now calls the local model before searching for papers. A returned paper list proves that the model response passed validation.

The first local analysis can pause while the model starts, so a quiet spinner during that pause is expected.

  • Enter the same idea in the Research idea field.
  • Enter your Semantic Scholar API key in the masked field.
  • Click Analyze research idea.

You should see the relevance-ranked paper list return after the local model finishes. This proves the generated query now drives retrieval.

Does the analysis stop before the papers appear?

Confirm that Ollama is still running in the background. Check that the local Granite model remains available from the setup step.

Ask for help with the local request.

  • Stop the running Streamlit process in its PowerShell window.
  • Inside main(), add this component display directly before the closest-prior-work heading:
    st.markdown("## Structured research components")
    st.dataframe(decomposition["components"], hide_index=True)
    st.write("Search query:", decomposition["search_query"])

What does this display add?

The dataframe makes all four component names plus their descriptions visible. The next line exposes the exact query used for retrieval.

Before you run the completed flow, do you expect the original paragraph or the generated query to reach Semantic Scholar?

  • Save app.py.
  • Launch the completed decomposition flow by running:
streamlit run app.py

What should the completed page show?

The page should show exactly four named components followed by the generated search query. The relevance-ranked paper list should appear beneath that map.

  • Enter your research idea in the Research idea field.
  • Enter your Semantic Scholar API key in the masked field.
  • Click Analyze research idea.

That decomposition is working. Your app now exposes the four signals behind the search before it presents the matching papers.

Missing components or a search query?

Check that the display lines sit after the spinner block. Make sure they remain inside the try block.

Ask for help with the final data flow.

✔️ Awesome, I've got everything!

Save app.py before moving on.

ⓧ I'd like to double check the full app.py

import json
import time

import requests
import streamlit as st

# Configure the external services and response fields used by the app.
SEMANTIC_SCHOLAR_SEARCH_URL = "https://api.semanticscholar.org/graph/v1/paper/search"
OLLAMA_CHAT_URL = "http://localhost:11434/api/chat"
MODEL_NAME = "ibm/granite4.1:3b"
PAPER_FIELDS = "paperId,title,abstract,year,authors,url"

DECOMPOSITION_SCHEMA = {
    "type": "object",
    "properties": {
        "search_query": {"type": "string"},
        "components": {
            "type": "array",
            "minItems": 4,
            "maxItems": 4,
            "items": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "description": {"type": "string"},
                },
                "required": ["name", "description"],
                "additionalProperties": False,
            },
        },
    },
    "required": ["search_query", "components"],
    "additionalProperties": False,
}


def ollama_json(messages, schema):
    # Ask the local model for one non-streaming response constrained by a schema.
    response = requests.post(
        OLLAMA_CHAT_URL,
        json={
            "model": MODEL_NAME,
            "messages": messages,
            "stream": False,
            "format": schema,
            "options": {"temperature": 0},
        },
        timeout=180,
    )
    response.raise_for_status()
    content = response.json()["message"]["content"]
    return json.loads(content)


def decompose_idea(idea):
    # Convert the original idea into four components and one retrieval query.
    result = ollama_json(
        [
            {
                "role": "system",
                "content": (
                    "You decompose research ideas for literature search. "
                    "Return exactly four distinct scientific components and one "
                    "concise plain-text search query. Components should cover the "
                    "population or system, method or intervention, context or data, "
                    "and intended outcome when those concepts are present."
                ),
            },
            {"role": "user", "content": idea},
        ],
        DECOMPOSITION_SCHEMA,
    )

    components = result.get("components", [])
    search_query = result.get("search_query", "").strip()
    if len(components) != 4 or not search_query:
        raise ValueError("The local model returned an incomplete decomposition.")

    cleaned_components = []
    for component in components:
        name = str(component.get("name", "")).strip()
        description = str(component.get("description", "")).strip()
        if not name or not description:
            raise ValueError("A research component was missing a name or description.")
        cleaned_components.append({"name": name, "description": description})

    return {
        "search_query": search_query.replace("-", " "),
        "components": cleaned_components,
    }


def search_papers(query, api_key):
    # Normalize the runtime key before placing it in the authentication header.
    clean_key = api_key.strip().strip("\"'")
    # Retry rate-limited requests after one second, then two seconds.
    for attempt in range(3):
        response = requests.get(
            SEMANTIC_SCHOLAR_SEARCH_URL,
            params={"query": query, "limit": 10, "fields": PAPER_FIELDS},
            headers={"x-api-key": clean_key},
            timeout=30,
        )
        if response.status_code != 429:
            response.raise_for_status()
            break
        if attempt == 2:
            response.raise_for_status()
        time.sleep(2 ** attempt)
    payload = response.json()
    papers = [paper for paper in payload.get("data", []) if paper.get("abstract")][:5]
    if not papers:
        raise ValueError("Semantic Scholar returned no papers with abstracts.")
    return papers


def main():
    # Render the form and run decomposition before paper retrieval.
    st.markdown("# ResearchGap")
    st.write(
        "Turn a research idea into components, nearby papers, a validated evidence "
        "matrix, and cautious gap signals."
    )

    with st.form("research_form"):
        idea = st.text_area(
            "Research idea",
            height=180,
            placeholder="Describe the population, method, context, and outcome.",
        )
        api_key = st.text_input("Semantic Scholar API key", type="password")
        submitted = st.form_submit_button("Analyze research idea", type="primary")

    if not submitted:
        return

    if not idea.strip() or not api_key.strip():
        st.error("Enter both a research idea and a Semantic Scholar API key.")
        return

    try:
        with st.spinner("Mapping the idea to prior work...", show_time=True):
            decomposition = decompose_idea(idea.strip())
            papers = search_papers(decomposition["search_query"], api_key)

        st.markdown("## Structured research components")
        st.dataframe(decomposition["components"], hide_index=True)
        st.write("Search query:", decomposition["search_query"])

        st.markdown("## Closest prior work in this sample")
        for index, paper in enumerate(papers, start=1):
            authors = ", ".join(
                author.get("name", "Unknown") for author in paper.get("authors", [])
            )
            st.write(index, paper.get("title", "Untitled"))
            st.write(
                f"{authors or 'Authors unavailable'} | "
                f"{paper.get('year') or 'Year unavailable'}"
            )
            if paper.get("url"):
                st.write(paper["url"])
    except requests.RequestException as error:
        st.error(f"API request failed: {error}")
    except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
        st.error(f"Analysis failed: {error}")


if __name__ == "__main__":
    main()

Your idea now has a visible structure before retrieval begins. Next, you'll assess every paper against every component with quote-validated evidence.

Build a Quote-Validated Evidence Matrix

Your app now breaks a research idea into four components before retrieving nearby papers. That structure gives every paper a consistent set of questions to answer.

A component label can still hide a hallucinated explanation. In this step, you will build an evidence matrix that keeps positive or contradictory judgments only when their quotes occur in the matching abstract.

In this step, get ready to:
  • Define the structured evidence contract for every paper-component pair.
  • Validate labels against the allowed coverage values.
  • Display a six-column matrix containing quote-checked evidence.
Define the evidence contract

A JSON Schema gives Ollama the required shape for each assessment. The schema also limits coverage to five labels.

  • Find the final closing brace of DECOMPOSITION_SCHEMA in app.py.
  • Place the following EVIDENCE_SCHEMA block directly below it at column zero, with no spaces or tabs before the variable name:
EVIDENCE_SCHEMA = {
    "type": "object",
    "properties": {
        "assessments": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "paper_id": {"type": "string"},
                    "component_name": {"type": "string"},
                    "label": {"type": "string", "enum": [
                        "supported", "partial", "contradicts",
                        "absent", "unclear",
                    ]},
                    "quote": {"type": "string"},
                },
                "required": ["paper_id", "component_name", "label", "quote"],
                "additionalProperties": False,
            },
        }
    },
    "required": ["assessments"],
    "additionalProperties": False,
}

What does this schema enforce?

  • The assessments array holds the model's flat evidence records.
  • Each record identifies one paper plus one component.
  • The enum restricts the model to the five coverage labels used by ResearchGap.
  • The required fields ensure that every returned record includes a quote field.
  • Save app.py.
  • Refresh the running ResearchGap page in your browser.

You should still see the research form without a Python syntax error. This confirms that the evidence schema loads with the existing app.

Seeing a schema scope or syntax error?

A NameError: EVIDENCE_SCHEMA is not defined usually means the schema was indented inside another function. Move the entire assignment to the left edge of app.py so assess_evidence() can access it globally.

If Python reports a syntax error instead, check the commas after the nested dictionaries. Confirm that the final closing brace appears after additionalProperties.

Help me check the scope and structure of EVIDENCE_SCHEMA.

The schema controls the response shape. The prompt controls what the local model evaluates within that shape.

  • In app.py, place the following functions below search_papers by adding this code:
def assess_evidence(components, papers):
    paper_input = [
        {"paper_id": paper["paperId"], "title": paper.get("title", "Untitled"),
         "abstract": paper.get("abstract", "")}
        for paper in papers
    ]
    prompt = (
        "Assess every paper against every research component. Use only the supplied "
        "abstracts. Return exactly one assessment for each paper-component pair. "
        "Use supported when the component is directly addressed, partial when only "
        "part is addressed, contradicts when the abstract gives opposing evidence, "
        "absent when it is not addressed, and unclear when the abstract is insufficient. "
        "For supported, partial, or contradicts, quote an exact continuous substring "
        "from the abstract. For absent or unclear, return an empty quote.\n\n"
        f"Components:\n{json.dumps(components, ensure_ascii=False)}\n\n"
        f"Papers:\n{json.dumps(paper_input, ensure_ascii=False)}"
    )
    result = ollama_json(
        [{"role": "system", "content": "You are a conservative scientific evidence extractor."},
         {"role": "user", "content": prompt}],
        EVIDENCE_SCHEMA,
    )
    return result.get("assessments", [])


def normalize_text(text):
    return " ".join(str(text or "").split()).casefold()

How is the assessment grounded?

  • The paper_input list sends only paper IDs, titles, and abstracts to the local model.
  • The prompt requires one assessment for every paper-component pair.
  • Positive or contradictory labels require an exact continuous abstract quote.
  • The normalize_text() helper collapses whitespace before applying case-insensitive comparison.
  • Save app.py.
  • Refresh the running ResearchGap page in your browser.

You should see the existing research form plus the four-component report after submission. This regression check confirms that both new functions load successfully.

App no longer reloads?

Confirm that assess_evidence() sits outside search_papers(). Both function definitions must begin at the left edge of the file.

Check that the prompt strings remain inside the surrounding parentheses. Help me diagnose the function placement or string syntax in assess_evidence().

Validate every paper-component pair

Structured output limits the model's format. Deterministic application logic decides whether each returned quote deserves to survive.

  • In app.py, place the following function below normalize_text by adding this code:
def validate_evidence(components, papers, assessments):
    allowed_labels = {"supported", "partial", "contradicts", "absent", "unclear"}
    quote_required = {"supported", "partial", "contradicts"}
    assessment_lookup = {}
    for assessment in assessments:
        key = (str(assessment.get("paper_id", "")).strip(), str(assessment.get("component_name", "")).strip().casefold())
        if key not in assessment_lookup:
            assessment_lookup[key] = assessment
    rows = []
    for paper in papers:
        abstract = paper.get("abstract", "")
        normalized_abstract = normalize_text(abstract)
        for component in components:
            key = (paper["paperId"], component["name"].casefold())
            assessment = assessment_lookup.get(key, {})
            label = str(assessment.get("label", "unclear")).casefold()
            quote = str(assessment.get("quote", "")).strip()
            if label not in allowed_labels:
                label, quote = "unclear", "Unrecognized coverage label."
            elif label in quote_required:
                normalized_quote = normalize_text(quote)
                if not normalized_quote or normalized_quote not in normalized_abstract:
                    label, quote = "unclear", "Quote rejected because it was not found in the abstract."
            else:
                quote = "No direct abstract evidence returned."
            rows.append(
                {"Paper": paper.get("title", "Untitled"), "Year": paper.get("year") or "", "Component": component["name"], "Coverage": label.title(), "Evidence": quote, "URL": paper.get("url") or ""}
            )
    return rows

What does the validator protect?

  • The lookup connects each assessment to its paper ID plus normalized component name.
  • The nested loops create every expected paper-component pair even when the model omits one.
  • The quote check compares normalized text against the matching normalized abstract.
  • A rejected quote becomes Unclear with an evidence message that explains the rejection.
  • Save app.py.
  • Refresh the running ResearchGap page in your browser.

You should see the existing app load without a traceback. The validator is now ready to turn model output into complete display rows.

Seeing an indentation error?

Keep the component loop inside the paper loop. Keep rows.append() inside the component loop so each pair produces one row.

Confirm that return rows aligns with the first for statement. Help me fix the loop indentation in validate_evidence().

Display and inspect the matrix

The final wiring sends the retrieved abstracts through assessment before validation. Only the validated rows reach Streamlit.

  • In main(), find the call to search_papers inside the spinner block.
  • Place the following calls directly below that search call:
            assessments = assess_evidence(decomposition["components"], papers)
            rows = validate_evidence(
                decomposition["components"], papers, assessments
            )

What happens during analysis?

The first call collects the model's schema-constrained assessments. The second call verifies every pair before creating the table rows.

  • Find the existing loop that displays the retrieved papers.
  • Place the following matrix renderer below that loop:
    st.markdown("## Validated evidence matrix")
    st.dataframe(rows, hide_index=True)

What reaches the table?

With five papers and four components, the validated dataframe contains twenty rows. Each row contains Paper, Year, Component, Coverage, Evidence, and URL from validated output only.

  • Save app.py.

Before you run the app, do you expect every model-generated supporting quote to pass the local substring check?

  • Return to PowerShell from earlier and start the updated app by running this command:
streamlit run app.py

What does this command do?

This starts the local ResearchGap app from app.py. The browser displays the updated interface served by Streamlit.

Your API key remains in the masked form field for this analysis. Keep the key out of screenshots by capturing only the report area.

  • Enter the same research idea in the Research idea field.
  • Enter your key in the Semantic Scholar API key field.
  • Click Analyze research idea.
  • Scroll to the Validated evidence matrix section.

You should see one row for every retrieved-paper and component combination. With five papers plus four components, the complete result contains twenty rows.

Every Supported, Partial, or Contradicts row should contain an abstract quote. A required quote that fails validation appears as Unclear with the rejection message.

Matrix missing or analysis failing?

Confirm that Ollama is still running locally. Check that assess_evidence() receives decomposition["components"] plus papers.

A smaller row count can indicate a model or request failure before validation completes. Help me troubleshoot a missing or incomplete validated evidence matrix.

✔️ Awesome, I've got everything!

Your app.py now assesses every paper-component pair before displaying quote-validated rows.

ⓧ I'd like to double check the full app.py

import json
import time

import requests
import streamlit as st

# Configure the external services and response fields used by the app.
SEMANTIC_SCHOLAR_SEARCH_URL = "https://api.semanticscholar.org/graph/v1/paper/search"
OLLAMA_CHAT_URL = "http://localhost:11434/api/chat"
MODEL_NAME = "ibm/granite4.1:3b"
PAPER_FIELDS = "paperId,title,abstract,year,authors,url"

DECOMPOSITION_SCHEMA = {
    "type": "object",
    "properties": {
        "search_query": {"type": "string"},
        "components": {
            "type": "array",
            "minItems": 4,
            "maxItems": 4,
            "items": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "description": {"type": "string"},
                },
                "required": ["name", "description"],
                "additionalProperties": False,
            },
        },
    },
    "required": ["search_query", "components"],
    "additionalProperties": False,
}

EVIDENCE_SCHEMA = {
    "type": "object",
    "properties": {
        "assessments": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "paper_id": {"type": "string"},
                    "component_name": {"type": "string"},
                    "label": {
                        "type": "string",
                        "enum": [
                            "supported",
                            "partial",
                            "contradicts",
                            "absent",
                            "unclear",
                        ],
                    },
                    "quote": {"type": "string"},
                },
                "required": [
                    "paper_id",
                    "component_name",
                    "label",
                    "quote",
                ],
                "additionalProperties": False,
            },
        }
    },
    "required": ["assessments"],
    "additionalProperties": False,
}


def ollama_json(messages, schema):
    # Ask the local model for one non-streaming response constrained by a schema.
    response = requests.post(
        OLLAMA_CHAT_URL,
        json={
            "model": MODEL_NAME,
            "messages": messages,
            "stream": False,
            "format": schema,
            "options": {"temperature": 0},
        },
        timeout=180,
    )
    response.raise_for_status()
    content = response.json()["message"]["content"]
    return json.loads(content)


def decompose_idea(idea):
    # Convert the original idea into four components and one retrieval query.
    result = ollama_json(
        [
            {
                "role": "system",
                "content": (
                    "You decompose research ideas for literature search. "
                    "Return exactly four distinct scientific components and one "
                    "concise plain-text search query. Components should cover the "
                    "population or system, method or intervention, context or data, "
                    "and intended outcome when those concepts are present."
                ),
            },
            {"role": "user", "content": idea},
        ],
        DECOMPOSITION_SCHEMA,
    )

    components = result.get("components", [])
    search_query = result.get("search_query", "").strip()
    if len(components) != 4 or not search_query:
        raise ValueError("The local model returned an incomplete decomposition.")

    cleaned_components = []
    for component in components:
        name = str(component.get("name", "")).strip()
        description = str(component.get("description", "")).strip()
        if not name or not description:
            raise ValueError("A research component was missing a name or description.")
        cleaned_components.append({"name": name, "description": description})

    return {
        "search_query": search_query.replace("-", " "),
        "components": cleaned_components,
    }


def search_papers(query, api_key):
    # Normalize the runtime key before placing it in the authentication header.
    clean_key = api_key.strip().strip("\"'")
    # Retry rate-limited requests after one second, then two seconds.
    for attempt in range(3):
        response = requests.get(
            SEMANTIC_SCHOLAR_SEARCH_URL,
            params={"query": query, "limit": 10, "fields": PAPER_FIELDS},
            headers={"x-api-key": clean_key},
            timeout=30,
        )
        if response.status_code != 429:
            response.raise_for_status()
            break
        if attempt == 2:
            response.raise_for_status()
        time.sleep(2 ** attempt)
    payload = response.json()
    papers = [paper for paper in payload.get("data", []) if paper.get("abstract")][:5]
    if not papers:
        raise ValueError("Semantic Scholar returned no papers with abstracts.")
    return papers


def assess_evidence(components, papers):
    # Send only paper identifiers, titles, and abstracts to the local model.
    paper_input = [
        {
            "paper_id": paper["paperId"],
            "title": paper.get("title", "Untitled"),
            "abstract": paper.get("abstract", ""),
        }
        for paper in papers
    ]
    prompt = (
        "Assess every paper against every research component. Use only the supplied "
        "abstracts. Return exactly one assessment for each paper-component pair. "
        "Use supported when the component is directly addressed, partial when only "
        "part is addressed, contradicts when the abstract gives opposing evidence, "
        "absent when it is not addressed, and unclear when the abstract is insufficient. "
        "For supported, partial, or contradicts, quote an exact continuous substring "
        "from the abstract. For absent or unclear, return an empty quote.\n\n"
        f"Components:\n{json.dumps(components, ensure_ascii=False)}\n\n"
        f"Papers:\n{json.dumps(paper_input, ensure_ascii=False)}"
    )
    result = ollama_json(
        [
            {
                "role": "system",
                "content": "You are a conservative scientific evidence extractor.",
            },
            {"role": "user", "content": prompt},
        ],
        EVIDENCE_SCHEMA,
    )
    return result.get("assessments", [])


def normalize_text(text):
    # Collapse whitespace and case differences before quote comparison.
    return " ".join(str(text or "").split()).casefold()


def validate_evidence(components, papers, assessments):
    # Build a lookup so every expected paper-component pair produces one row.
    allowed_labels = {
        "supported",
        "partial",
        "contradicts",
        "absent",
        "unclear",
    }
    quote_required = {"supported", "partial", "contradicts"}
    assessment_lookup = {}

    for assessment in assessments:
        key = (
            str(assessment.get("paper_id", "")).strip(),
            str(assessment.get("component_name", "")).strip().casefold(),
        )
        if key not in assessment_lookup:
            assessment_lookup[key] = assessment

    rows = []
    for paper in papers:
        abstract = paper.get("abstract", "")
        normalized_abstract = normalize_text(abstract)

        for component in components:
            key = (paper["paperId"], component["name"].casefold())
            assessment = assessment_lookup.get(key, {})
            label = str(assessment.get("label", "unclear")).casefold()
            quote = str(assessment.get("quote", "")).strip()

            if label not in allowed_labels:
                label = "unclear"
                quote = "Unrecognized coverage label."
            elif label in quote_required:
                normalized_quote = normalize_text(quote)
                if not normalized_quote or normalized_quote not in normalized_abstract:
                    label = "unclear"
                    quote = "Quote rejected because it was not found in the abstract."
            else:
                quote = "No direct abstract evidence returned."

            rows.append(
                {
                    "Paper": paper.get("title", "Untitled"),
                    "Year": paper.get("year") or "",
                    "Component": component["name"],
                    "Coverage": label.title(),
                    "Evidence": quote,
                    "URL": paper.get("url") or "",
                }
            )

    return rows


def main():
    # Render the form and run the complete evidence-validation workflow.
    st.markdown("# ResearchGap")
    st.write(
        "Turn a research idea into components, nearby papers, a validated evidence "
        "matrix, and cautious gap signals."
    )

    with st.form("research_form"):
        idea = st.text_area(
            "Research idea",
            height=180,
            placeholder="Describe the population, method, context, and outcome.",
        )
        api_key = st.text_input("Semantic Scholar API key", type="password")
        submitted = st.form_submit_button("Analyze research idea", type="primary")

    if not submitted:
        return

    if not idea.strip() or not api_key.strip():
        st.error("Enter both a research idea and a Semantic Scholar API key.")
        return

    try:
        with st.spinner("Mapping the idea to prior work...", show_time=True):
            decomposition = decompose_idea(idea.strip())
            papers = search_papers(decomposition["search_query"], api_key)
            assessments = assess_evidence(decomposition["components"], papers)
            rows = validate_evidence(
                decomposition["components"], papers, assessments
            )

        st.markdown("## Structured research components")
        st.dataframe(decomposition["components"], hide_index=True)
        st.write("Search query:", decomposition["search_query"])

        st.markdown("## Closest prior work in this sample")
        for index, paper in enumerate(papers, start=1):
            authors = ", ".join(
                author.get("name", "Unknown") for author in paper.get("authors", [])
            )
            st.write(index, paper.get("title", "Untitled"))
            st.write(
                f"{authors or 'Authors unavailable'} | "
                f"{paper.get('year') or 'Year unavailable'}"
            )
            if paper.get("url"):
                st.write(paper["url"])

        st.markdown("## Validated evidence matrix")
        st.dataframe(rows, hide_index=True)
    except requests.RequestException as error:
        st.error(f"API request failed: {error}")
    except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
        st.error(f"Analysis failed: {error}")


if __name__ == "__main__":
    main()

That is the key guardrail working. Your app now separates abstract evidence from model interpretation before anything reaches the matrix.

Next, you will turn these validated rows into a cautious brief of well-explored signals plus candidate gaps.

Generate a Cautious Gap Brief

Your validated evidence matrix now ties every coverage judgment to a retrieved abstract. The rows still take time to scan when you want the main patterns.

This step turns those rows into sample-level signals without overstating what five abstracts can prove. The completed app separates well-explored signals from candidate gaps while keeping the limits visible.

In this step, get ready to:
  • Generate a structured summary from the validated evidence matrix.
  • Render separate well-explored and candidate-gap sections.
  • Surface request failures and malformed analysis output as visible app errors.
Define the summary contract

A JSON Schema gives the local model a fixed response shape. Each array allows up to three items so the brief stays focused on the strongest patterns.

  • In app.py, add the summary schema below EVIDENCE_SCHEMA by copying this code:
SUMMARY_SCHEMA = {
    "type": "object",
    "properties": {
        "well_explored": {
            "type": "array",
            "items": {"type": "string"},
            "maxItems": 3,
        },
        "candidate_gaps": {
            "type": "array",
            "items": {"type": "string"},
            "maxItems": 3,
        },
    },
    "required": ["well_explored", "candidate_gaps"],
    "additionalProperties": False,
}

What does this schema control?

  • The well_explored array holds repeated coverage patterns found in the sample.
  • The candidate_gaps array holds patterns with missing or weak coverage in the sample.
  • The maxItems values keep each section at three items or fewer.

The summary must receive evidence that has already passed your quote checks. Sending only the validated rows keeps rejected model claims out of the final interpretation.

  • Add summarize_matrix(rows) below validate_evidence(components, papers, assessments) by copying this function:
def summarize_matrix(rows):
    summary_input = [
        {
            "paper": row["Paper"],
            "component": row["Component"],
            "coverage": row["Coverage"],
            "evidence": row["Evidence"],
        }
        for row in rows
    ]
    result = ollama_json(
        [
            {
                "role": "system",
                "content": (
                    "Summarize only the supplied validated evidence matrix. "
                    "Describe repeated coverage as well-explored signals in this sample. "
                    "Describe missing or weak coverage as candidate gaps in this sample. "
                    "Never claim that an idea is novel or that the literature is exhaustive."
                ),
            },
            {
                "role": "user",
                "content": json.dumps(summary_input, ensure_ascii=False),
            },
        ],
        SUMMARY_SCHEMA,
    )
    return {
        "well_explored": [str(item) for item in result.get("well_explored", [])],
        "candidate_gaps": [str(item) for item in result.get("candidate_gaps", [])],
    }

What does this function do?

  • The summary_input list keeps only the paper name, component, validated coverage label, and validated evidence.
  • The system message restricts every conclusion to patterns found in this sample.
  • The call to ollama_json() asks Ollama for output that follows SUMMARY_SCHEMA.
  • The returned dictionary always provides separate lists for the two kinds of signal.
  • Save app.py.
  • Return to the browser tab running ResearchGap.
  • Refresh the page to confirm the ResearchGap form still loads without a Python syntax error.

Your summary contract and local-model helper now load successfully. The evidence matrix is ready to become a concise brief.

Seeing a syntax error?

Check that SUMMARY_SCHEMA appears after the closing brace of EVIDENCE_SCHEMA. Confirm that summarize_matrix() starts at the left edge of app.py.

Ask for help with the exact traceback if the form still does not load: Help me find the syntax or indentation error in my new summary schema and summarize_matrix function.

Render the sample-level brief

The completed Streamlit report needs a consistent way to display short lists. A small helper also provides a clear message when the model returns an empty array.

  • Add display_bullets(items, empty_message) below summarize_matrix(rows) by copying this function:
def display_bullets(items, empty_message):
    if not items:
        st.write(empty_message)
        return
    for item in items:
        st.write(f"- {item}")

What does this helper do?

  • An empty list produces a specific fallback message instead of a blank report section.
  • A populated list renders one visible bullet for each returned signal.
  • Update the parameter list for display_report() so it receives summary after rows.
  • Below st.dataframe(rows, hide_index=True), add the two summary sections with the fixed caution by copying this code:
    st.markdown("## Well-explored signals in this sample")
    display_bullets(
        summary["well_explored"],
        "No repeated well-explored signal was identified in this sample.",
    )

    st.markdown("## Candidate gaps in this sample")
    display_bullets(
        summary["candidate_gaps"],
        "No candidate gap was identified from this sample.",
    )

    st.warning(
        "This is an abstract-level scan of five relevance-ranked papers, not a "
        "novelty determination. Confirm any gap with expert review, broader databases, "
        "full-text reading, and citation chaining."
    )

Why keep the warning fixed?

The model summarizes patterns from up to five abstracts. The fixed warning keeps every report tied to that limited evidence base.

Expert review, broader database searches, full-text reading, and citation chaining remain necessary before making a novelty claim.

  • Well-explored signals describe repeated coverage inside the retrieved sample.
  • Candidate gaps describe weak or missing coverage inside the retrieved sample.
  • In main(), replace the existing call to display_report() with these two lines:
            summary = summarize_matrix(rows)
        display_report(decomposition, papers, rows, summary)

How does the summary reach the report?

The first line sends the quote-validated rows to the local model. The second line passes the structured result into display_report() for rendering.

  • Save app.py.
  • Return to the ResearchGap form in your browser.
  • Enter the same research idea you used earlier in the Research idea field.
  • Enter your key in the masked Semantic Scholar API key field.

The local analysis can take up to three minutes while the model processes every validated row. Before you submit, which patterns do you expect the brief to place under each heading?

  • Click Analyze research idea.

You should see the validated evidence matrix followed by well-explored signals and candidate gaps. A warning should explain the sample limit beneath both lists.

Summary sections missing?

Confirm that display_report() receives four arguments in the same order as its four parameters. Check that summary = summarize_matrix(rows) sits after validate_evidence() returns the rows.

Use the visible traceback or app error to identify the failing function: Help me trace why my ResearchGap summary sections are missing after the evidence matrix.

Surface analysis failures

Network requests and structured model output can fail in different ways. Catching those failures keeps missing abstracts, malformed JSON, and incomplete responses visible inside the app.

  • In main(), locate the analysis workflow after the empty-field guard.
  • Replace that workflow with this error-handled version:
    try:
        with st.spinner("Mapping the idea to prior work...", show_time=True):
            decomposition = decompose_idea(idea.strip())
            papers = search_papers(decomposition["search_query"], api_key.strip())
            assessments = assess_evidence(decomposition["components"], papers)
            rows = validate_evidence(
                decomposition["components"], papers, assessments
            )
            summary = summarize_matrix(rows)
        display_report(decomposition, papers, rows, summary)
    except requests.RequestException as error:
        st.error(f"API request failed: {error}")
    except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
        st.error(f"Analysis failed: {error}")

How are failures handled?

  • The try block runs decomposition, paper retrieval, evidence assessment, quote validation, and summary generation as one workflow.
  • The requests.RequestException handler displays failures from the external paper request or the local model request.
  • The second handler displays malformed JSON, missing response fields, missing abstracts, and invalid local-model output.
  • Each failure reaches st.error() so the page shows what interrupted the analysis.

✔️ Awesome, I've got everything!

Save app.py before running the final analysis.

ⓧ I'd like to double check the full app.py

import json
import time

import requests
import streamlit as st

# Configure the external services and response fields used by the app.
SEMANTIC_SCHOLAR_SEARCH_URL = "https://api.semanticscholar.org/graph/v1/paper/search"
OLLAMA_CHAT_URL = "http://localhost:11434/api/chat"
MODEL_NAME = "ibm/granite4.1:3b"
PAPER_FIELDS = "paperId,title,abstract,year,authors,url"

DECOMPOSITION_SCHEMA = {
    "type": "object",
    "properties": {
        "search_query": {"type": "string"},
        "components": {
            "type": "array",
            "minItems": 4,
            "maxItems": 4,
            "items": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "description": {"type": "string"},
                },
                "required": ["name", "description"],
                "additionalProperties": False,
            },
        },
    },
    "required": ["search_query", "components"],
    "additionalProperties": False,
}

EVIDENCE_SCHEMA = {
    "type": "object",
    "properties": {
        "assessments": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "paper_id": {"type": "string"},
                    "component_name": {"type": "string"},
                    "label": {
                        "type": "string",
                        "enum": [
                            "supported",
                            "partial",
                            "contradicts",
                            "absent",
                            "unclear",
                        ],
                    },
                    "quote": {"type": "string"},
                },
                "required": [
                    "paper_id",
                    "component_name",
                    "label",
                    "quote",
                ],
                "additionalProperties": False,
            },
        }
    },
    "required": ["assessments"],
    "additionalProperties": False,
}

SUMMARY_SCHEMA = {
    "type": "object",
    "properties": {
        "well_explored": {
            "type": "array",
            "items": {"type": "string"},
            "maxItems": 3,
        },
        "candidate_gaps": {
            "type": "array",
            "items": {"type": "string"},
            "maxItems": 3,
        },
    },
    "required": ["well_explored", "candidate_gaps"],
    "additionalProperties": False,
}


def ollama_json(messages, schema):
    # Ask the local model for one non-streaming response constrained by a schema.
    response = requests.post(
        OLLAMA_CHAT_URL,
        json={
            "model": MODEL_NAME,
            "messages": messages,
            "stream": False,
            "format": schema,
            "options": {"temperature": 0},
        },
        timeout=180,
    )
    response.raise_for_status()
    content = response.json()["message"]["content"]
    return json.loads(content)


def decompose_idea(idea):
    # Convert the original idea into four components and one retrieval query.
    result = ollama_json(
        [
            {
                "role": "system",
                "content": (
                    "You decompose research ideas for literature search. "
                    "Return exactly four distinct scientific components and one "
                    "concise plain-text search query. Components should cover the "
                    "population or system, method or intervention, context or data, "
                    "and intended outcome when those concepts are present."
                ),
            },
            {"role": "user", "content": idea},
        ],
        DECOMPOSITION_SCHEMA,
    )

    components = result.get("components", [])
    search_query = result.get("search_query", "").strip()
    if len(components) != 4 or not search_query:
        raise ValueError("The local model returned an incomplete decomposition.")

    cleaned_components = []
    for component in components:
        name = str(component.get("name", "")).strip()
        description = str(component.get("description", "")).strip()
        if not name or not description:
            raise ValueError("A research component was missing a name or description.")
        cleaned_components.append({"name": name, "description": description})

    return {
        "search_query": search_query.replace("-", " "),
        "components": cleaned_components,
    }


def search_papers(query, api_key):
    # Normalize the runtime key before placing it in the authentication header.
    clean_key = api_key.strip().strip("\"'")
    # Retry rate-limited requests after one second, then two seconds.
    for attempt in range(3):
        response = requests.get(
            SEMANTIC_SCHOLAR_SEARCH_URL,
            params={"query": query, "limit": 10, "fields": PAPER_FIELDS},
            headers={"x-api-key": clean_key},
            timeout=30,
        )
        if response.status_code != 429:
            response.raise_for_status()
            break
        if attempt == 2:
            response.raise_for_status()
        time.sleep(2 ** attempt)
    payload = response.json()
    papers = [paper for paper in payload.get("data", []) if paper.get("abstract")][:5]
    if not papers:
        raise ValueError("Semantic Scholar returned no papers with abstracts.")
    return papers


def assess_evidence(components, papers):
    # Send only paper identifiers, titles, and abstracts to the local model.
    paper_input = [
        {
            "paper_id": paper["paperId"],
            "title": paper.get("title", "Untitled"),
            "abstract": paper.get("abstract", ""),
        }
        for paper in papers
    ]
    prompt = (
        "Assess every paper against every research component. Use only the supplied "
        "abstracts. Return exactly one assessment for each paper-component pair. "
        "Use supported when the component is directly addressed, partial when only "
        "part is addressed, contradicts when the abstract gives opposing evidence, "
        "absent when it is not addressed, and unclear when the abstract is insufficient. "
        "For supported, partial, or contradicts, quote an exact continuous substring "
        "from the abstract. For absent or unclear, return an empty quote.\n\n"
        f"Components:\n{json.dumps(components, ensure_ascii=False)}\n\n"
        f"Papers:\n{json.dumps(paper_input, ensure_ascii=False)}"
    )
    result = ollama_json(
        [
            {
                "role": "system",
                "content": "You are a conservative scientific evidence extractor.",
            },
            {"role": "user", "content": prompt},
        ],
        EVIDENCE_SCHEMA,
    )
    return result.get("assessments", [])


def normalize_text(text):
    # Collapse whitespace and case differences before quote comparison.
    return " ".join(str(text or "").split()).casefold()


def validate_evidence(components, papers, assessments):
    # Build a lookup so every expected paper-component pair produces one row.
    allowed_labels = {
        "supported",
        "partial",
        "contradicts",
        "absent",
        "unclear",
    }
    quote_required = {"supported", "partial", "contradicts"}
    assessment_lookup = {}

    for assessment in assessments:
        key = (
            str(assessment.get("paper_id", "")).strip(),
            str(assessment.get("component_name", "")).strip().casefold(),
        )
        if key not in assessment_lookup:
            assessment_lookup[key] = assessment

    rows = []
    for paper in papers:
        abstract = paper.get("abstract", "")
        normalized_abstract = normalize_text(abstract)

        for component in components:
            key = (paper["paperId"], component["name"].casefold())
            assessment = assessment_lookup.get(key, {})
            label = str(assessment.get("label", "unclear")).casefold()
            quote = str(assessment.get("quote", "")).strip()

            if label not in allowed_labels:
                label = "unclear"
                quote = "Unrecognized coverage label."
            elif label in quote_required:
                normalized_quote = normalize_text(quote)
                if not normalized_quote or normalized_quote not in normalized_abstract:
                    label = "unclear"
                    quote = "Quote rejected because it was not found in the abstract."
            else:
                quote = "No direct abstract evidence returned."

            rows.append(
                {
                    "Paper": paper.get("title", "Untitled"),
                    "Year": paper.get("year") or "",
                    "Component": component["name"],
                    "Coverage": label.title(),
                    "Evidence": quote,
                    "URL": paper.get("url") or "",
                }
            )

    return rows


def summarize_matrix(rows):
    # Keep the final interpretation limited to quote-validated matrix rows.
    summary_input = [
        {
            "paper": row["Paper"],
            "component": row["Component"],
            "coverage": row["Coverage"],
            "evidence": row["Evidence"],
        }
        for row in rows
    ]
    result = ollama_json(
        [
            {
                "role": "system",
                "content": (
                    "Summarize only the supplied validated evidence matrix. "
                    "Describe repeated coverage as well-explored signals in this sample. "
                    "Describe missing or weak coverage as candidate gaps in this sample. "
                    "Never claim that an idea is novel or that the literature is exhaustive."
                ),
            },
            {
                "role": "user",
                "content": json.dumps(summary_input, ensure_ascii=False),
            },
        ],
        SUMMARY_SCHEMA,
    )
    return {
        "well_explored": [str(item) for item in result.get("well_explored", [])],
        "candidate_gaps": [str(item) for item in result.get("candidate_gaps", [])],
    }


def display_bullets(items, empty_message):
    # Render one fallback message or one line per returned signal.
    if not items:
        st.write(empty_message)
        return
    for item in items:
        st.write(f"- {item}")


def display_report(decomposition, papers, rows, summary):
    # Present each stage of the completed analysis in retrieval order.
    st.markdown("## Structured research components")
    st.dataframe(decomposition["components"], hide_index=True)
    st.write("Search query:", decomposition["search_query"])

    st.markdown("## Closest prior work in this sample")
    for index, paper in enumerate(papers, start=1):
        authors = ", ".join(
            author.get("name", "Unknown") for author in paper.get("authors", [])
        )
        st.write(index, paper.get("title", "Untitled"))
        st.write(
            f"{authors or 'Authors unavailable'} | "
            f"{paper.get('year') or 'Year unavailable'}"
        )
        if paper.get("url"):
            st.write(paper["url"])

    st.markdown("## Validated evidence matrix")
    st.dataframe(rows, hide_index=True)

    st.markdown("## Well-explored signals in this sample")
    display_bullets(
        summary["well_explored"],
        "No repeated well-explored signal was identified in this sample.",
    )

    st.markdown("## Candidate gaps in this sample")
    display_bullets(
        summary["candidate_gaps"],
        "No candidate gap was identified from this sample.",
    )

    st.warning(
        "This is an abstract-level scan of five relevance-ranked papers, not a "
        "novelty determination. Confirm any gap with expert review, broader databases, "
        "full-text reading, and citation chaining."
    )


def main():
    # Collect runtime inputs and run the complete ResearchGap workflow.
    st.markdown("# ResearchGap")
    st.write(
        "Turn a research idea into components, nearby papers, a validated evidence "
        "matrix, and cautious gap signals."
    )

    with st.form("research_form"):
        idea = st.text_area(
            "Research idea",
            height=180,
            placeholder="Describe the population, method, context, and outcome.",
        )
        api_key = st.text_input("Semantic Scholar API key", type="password")
        submitted = st.form_submit_button("Analyze research idea", type="primary")

    if not submitted:
        return

    if not idea.strip() or not api_key.strip():
        st.error("Enter both a research idea and a Semantic Scholar API key.")
        return

    try:
        with st.spinner("Mapping the idea to prior work...", show_time=True):
            decomposition = decompose_idea(idea.strip())
            papers = search_papers(decomposition["search_query"], api_key)
            assessments = assess_evidence(decomposition["components"], papers)
            rows = validate_evidence(
                decomposition["components"], papers, assessments
            )
            summary = summarize_matrix(rows)
        display_report(decomposition, papers, rows, summary)
    except requests.RequestException as error:
        st.error(f"API request failed: {error}")
    except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
        st.error(f"Analysis failed: {error}")


if __name__ == "__main__":
    main()

Before you run the completed app, which report sections should appear after a successful analysis?

  • Stop the current Streamlit process using your terminal's interrupt control.
  • Start the completed ResearchGap app from the PowerShell terminal in your researchgap folder by running this command:
streamlit run app.py

What does this command verify?

This command loads the complete app.py file through Streamlit. A successful browser launch confirms that Python can import the file and start the app.

  • Return to the browser page opened by Streamlit.
  • Enter your research idea in the Research idea field.
  • Enter your key in the masked Semantic Scholar API key field.
  • Click Analyze research idea.

You should see exactly four structured components with a generated search query. You should also see up to five papers, the validated six-column matrix, both gap-brief sections, and the fixed caution.

That completes the full research loop. ResearchGap now turns a proposed idea into source-linked evidence and a sample-scoped interpretation without presenting the result as a novelty verdict.

Analysis stops with an app error?

A Semantic Scholar 429 response means the request reached the rate limit. The app waits for one second, then two seconds, before surfacing the final failure.

If the error remains, wait before submitting again and enter the API key without surrounding quotes. A local connection failure can instead point to Ollama.

Copy the visible error without including your API key. Help me diagnose the ResearchGap request safely.

Secret mission

Export the Evidence Matrix

Turn the validated evidence matrix into a portable CSV with one click. The export stays in memory while preserving titles and evidence quotes that contain commas or line breaks.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep your local resources ready, pause the running processes, or remove everything from your Windows PC. This project creates no deployed cloud resources or new ongoing service charges.

Resources you used:

  • The local researchgap folder with app.py, requirements.txt, and .venv.
  • The local Ollama application.
  • The %HOMEPATH%\.ollama folder containing the ibm/granite4.1:3b model plus local configuration.

Keep everything running

No action is needed. Choose this if you plan to keep improving ResearchGap or running new literature maps.

  • Keep the researchgap folder on your Desktop.
  • Leave Ollama installed on your PC.
  • Retain %HOMEPATH%\.ollama so the downloaded model remains available.

ResearchGap asks for your Semantic Scholar API key each time you run an analysis because the key is not stored in the project.

Pause - I'll come back to this later

Stop the local processes to free memory. Your project files plus downloaded model stay ready for next time.

  • Return to the PowerShell window where Streamlit is running.
  • Press Ctrl+C to stop Streamlit.
  • Right-click the Ollama icon in the Windows system tray.
  • Choose the quit option to stop the background application.
  • Refresh the ResearchGap browser tab.

The page should stop accepting new analyses. The Ollama icon should disappear from the Windows system tray.

Delete - I don't want to use this again

Deletion is permanent. These steps remove every local ResearchGap resource from your PC.

Back up what you want to keep:

  • Copy any ResearchGap code you want to keep to another folder.
  • Copy any researchgap_evidence.csv download you want to keep to another folder.

Stop the local processes:

  • Return to PowerShell if Streamlit is still running.
  • Press Ctrl+C to stop Streamlit.
  • Right-click the Ollama icon in the Windows system tray.
  • Choose the quit option to stop Ollama.

Remove Ollama:

  • Press the Windows key to open search.
  • Type Add or remove programs into the search bar.
  • Select Add or remove programs from the results.
  • Locate Ollama in the installed apps list.
  • Start the registered uninstaller for Ollama.
  • Complete the displayed removal prompts.

The local model folder is easy to overlook because its name starts with a full stop. File Explorer can jump to its exact path.

  • Paste %HOMEPATH%\.ollama into the File Explorer address bar.
  • Permanently delete the .ollama folder to remove the model plus its local configuration.

Remove the project files:

  • Return to your Desktop in File Explorer.
  • Permanently delete the researchgap folder.

Verify the cleanup:

  • Search the installed apps list for Ollama.
  • Check your Desktop in File Explorer for the researchgap folder.
  • Paste %HOMEPATH%\.ollama into the File Explorer address bar again.

You should no longer see Ollama in the installed apps list. The researchgap folder should be absent from your Desktop.

The %HOMEPATH%\.ollama path should no longer open. Your local ResearchGap resources are fully removed.

Nice Work!

Nice Work!

ResearchGap is ready! You built a local research-scoping dashboard that turns an idea into a traceable map of prior work.

You've learned how to:

  • Build a local Streamlit interface that turns a research idea into four searchable components. Retrieve up to five relevance-ranked papers through the Semantic Scholar Academic Graph API.
  • Use Ollama with ibm/granite4.1:3b to produce local structured analysis. Constrain each response with a JSON Schema.
  • Create a quote-validated evidence matrix with five coverage labels. Reject unsupported quotes before presenting candidate gaps as sample-level signals.
  • Complete the optional Secret Mission by adding a one-click CSV export for the validated matrix. Preserve commas and line breaks without writing temporary files to disk.

Ready to quiz yourself?