Artificial Intelligence

How to Build an AI Research Assistant with Python & Gemini (Step-by-Step Guide)

Build a Python AI research assistant in 4 simple steps. Search the web with DuckDuckGo, extract article text with Trafilatura, and let Google Gemini write your reports. Perfect for developers who want to automate tedious research.

S
Super Admin
6, Jul 2026
15 minutes read
1050 views
How to Build an AI Research Assistant with Python & Gemini (Step-by-Step Guide)

Let me paint you a picture. It's 11 PM, I'm staring at my screen, and I need to research "Docker vs Podman" for a blog post. I've got 15 tabs open, my brain is fried, and I'm just... tired. Sound familiar?

That night, instead of actually doing the research, I did what any reasonable developer would do — I spent the next few hours building a tool to do it for me.

The result? An AI research assistant that takes any topic, searches the web, reads the articles, and writes me a nice little report. All in about 30 seconds flat.

Today, I'm going to show you exactly how I built it. No fluff, no over-engineering — just a clean, minimal Python script that actually works.

What Does This Thing Actually Do?

Before we dive into code, let me show you what we're building. You type something like this:

"Docker vs Podman"

And you get back a report.md file that looks something like:

  • A proper title and timestamp

  • A well-structured synthesis of what different sources say

  • A numbered list of all the sources it actually used

The whole pipeline runs in five steps: search, extract, summarize, combine, and save. That's it.

The Architecture (It's Simpler Than You Think)

Here's the workflow in plain English:

  1. You give it a topic — anything from "React vs Vue 2024" to "best indoor plants for low light"

  2. It searches the web — using DuckDuckGo (no API keys needed for search!)

  3. It grabs the top articles — extracts the actual content, not the ads and sidebars

  4. It summarizes each source — using Google's Gemini AI

  5. It writes a final report — combining all the summaries into one coherent document

The entire codebase is under 300 lines of Python split across five files. Each file does exactly one thing. No crazy abstractions, no frameworks you need to learn.

Step 1: Searching Without Breaking a Sweat

The first thing we need is search. Now, you could use Google Custom Search API, but that requires API keys and billing setup. I wanted something that works immediately.

DuckDuckGo to the rescue. There's a Python library called ddgs that gives you programmatic access to DuckDuckGo's search results. No API key required, no rate limiting headaches.

search.py

"""
search.py
Handles web search via DuckDuckGo and returns the top N result URLs
for a given research topic.
"""

from __future__ import annotations

import logging
from typing import List

from ddgs import DDGS

logger = logging.getLogger(__name__)


def search_topic(topic: str, num_results: int = 3) -> List[str]:
    """
    Search DuckDuckGo for a topic and return a list of result URLs.

    Args:
        topic: The research topic / query string.
        num_results: How many URLs to return (default 3).

    Returns:
        A list of URL strings. May contain fewer than num_results
        entries if the search doesn't return enough results.
    """
    if not topic or not topic.strip():
        raise ValueError("Search topic must not be empty.")

    urls: List[str] = []

    try:
        with DDGS() as ddgs:
            results = ddgs.text(topic, max_results=num_results)

            for result in results:
                url = result.get("href") or result.get("url")
                if url:
                    urls.append(url)
                if len(urls) >= num_results:
                    break
    except Exception as exc:  # noqa: BLE001 - surface a clean error upstream
        logger.error("Search failed for topic '%s': %s", topic, exc)
        raise RuntimeError(f"Web search failed: {exc}") from exc

    if not urls:
        raise RuntimeError(f"No search results found for topic: '{topic}'")

    logger.info("Found %d URLs for topic '%s'", len(urls), topic)
    return urls


if __name__ == "__main__":
    # Quick manual test: python search.py "Docker vs Podman"
    import sys

    logging.basicConfig(level=logging.INFO)
    query = " ".join(sys.argv[1:]) or "Docker vs Podman"
    for i, u in enumerate(search_topic(query), start=1):
        print(f"{i}. {u}")

The function takes a topic and a number of results (default is 3), hits DuckDuckGo, and returns a list of URLs. That's it. If the search fails or returns nothing useful, it raises an error early rather than failing silently later.

Pro tip: I set the default to 3 results because that's usually enough to get a good picture of a topic without hitting API rate limits or making the report too long.

Step 2: Actually Reading Web Pages (The Tricky Part)

Getting a URL is easy. Getting clean, readable text from that URL? That's where things get interesting.

Web pages are messy. They have navigation bars, sidebars, cookie notices, ads, related posts, comment sections, and about seventeen different tracking scripts. What you actually want is the article text — just the content.

This is where trafilatura comes in clutch. It's a Python library specifically designed for extracting article text from HTML pages. It's not just stripping HTML tags — it's actually smart enough to identify where the main content is.

scraper.py

"""
scraper.py
Fetches web pages and extracts clean, readable article text using
Trafilatura, with `requests` used for the raw HTTP fetch.
"""

from __future__ import annotations

import logging
from typing import Optional

import requests
import trafilatura

logger = logging.getLogger(__name__)

DEFAULT_HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (compatible; AIResearchAssistant/1.0; "
        "+https://example.com/bot)"
    )
}


def fetch_html(url: str, timeout: int = 15) -> Optional[str]:
    """
    Download raw HTML for a URL. Returns None on failure instead of
    raising, since a single bad URL shouldn't kill the whole pipeline.
    """
    try:
        response = requests.get(
            url, headers=DEFAULT_HEADERS, timeout=timeout, allow_redirects=True
        )
        response.raise_for_status()
        return response.text
    except requests.RequestException as exc:
        logger.warning("Failed to fetch %s: %s", url, exc)
        return None


def extract_text(url: str, timeout: int = 15) -> Optional[str]:
    """
    Fetch a URL and extract its main article text.

    Tries trafilatura's own downloader first (it handles encoding/
    compression well), then falls back to `requests` + trafilatura's
    extractor if that fails.

    Returns:
        The extracted text, or None if extraction wasn't possible.
    """
    downloaded = trafilatura.fetch_url(url)

    if not downloaded:
        downloaded = fetch_html(url, timeout=timeout)

    if not downloaded:
        logger.warning("Could not retrieve content for %s", url)
        return None

    text = trafilatura.extract(
        downloaded,
        include_comments=False,
        include_tables=False,
        favor_precision=True,
    )

    if not text or not text.strip():
        logger.warning("No extractable text found at %s", url)
        return None

    return text.strip()


def extract_texts(urls: list[str], timeout: int = 15) -> dict[str, str]:
    """
    Extract text for multiple URLs.

    Returns:
        A dict mapping URL -> extracted text, skipping URLs that
        failed to yield usable content.
    """
    results: dict[str, str] = {}
    for url in urls:
        text = extract_text(url, timeout=timeout)
        if text:
            results[url] = text
        else:
            logger.info("Skipping %s (no usable text extracted)", url)
    return results


if __name__ == "__main__":
    import sys

    logging.basicConfig(level=logging.INFO)
    test_url = sys.argv[1] if len(sys.argv) > 1 else "https://example.com"
    content = extract_text(test_url)
    print(content[:1000] if content else "No text extracted.")

Here's my approach: I try trafilatura's built-in URL fetcher first (it handles encodings and compression well), and if that fails, I fall back to requests with a custom user agent. This gives us the best of both worlds.

I also made a deliberate design choice: if a page fails, we skip it. We don't crash the whole pipeline. Your research shouldn't die just because one random blog decided to block bots.

Step 3: Making Gemini Do the Hard Work

Now comes the fun part — actually understanding what these articles say.

I'm using Google's Gemini API with the Flash Lite model. Why Flash Lite? Because it's fast, cheap, and more than capable enough for summarizing articles. We're not writing poetry here; we're extracting key points.

For each URL we successfully extracted, we send the article text to Gemini with a prompt that goes something like: "Here's an article about [topic], give me the key points in a concise summary."

gemini.py

"""
gemini.py
Thin wrapper around the Gemini API for two jobs:
  1. Summarizing a single page's extracted text.
  2. Combining several summaries into one final research report.

Uses the new `google-genai` SDK (genai.Client), not the older
`google-generativeai` package.
"""

from __future__ import annotations

import logging
import os
from typing import List

from google import genai
from dotenv import load_dotenv

logger = logging.getLogger(__name__)

load_dotenv()

_MODEL_NAME = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
_API_KEY = os.getenv("GEMINI_API_KEY")

_client: genai.Client | None = None


def _get_client() -> genai.Client:
    """Lazily create and cache the Gemini Developer API client."""
    global _client
    if _client is not None:
        return _client

    if not _API_KEY:
        raise RuntimeError(
            "GEMINI_API_KEY is not set. Copy .env.example to .env and add your key."
        )

    _client = genai.Client(api_key=_API_KEY)
    return _client


def summarize_page(url: str, text: str, topic: str, max_chars: int = 12000) -> str:
    """
    Summarize a single page's extracted text in the context of the
    overall research topic.

    Args:
        url: Source URL (included for context/attribution in the prompt).
        text: Raw extracted article text.
        topic: The overall research topic, to keep the summary focused.
        max_chars: Truncate very long articles before sending to the API.

    Returns:
        A concise summary string.
    """
    trimmed = text[:max_chars]

    prompt = f"""You are a research assistant. Summarize the following web page
    content in the context of this research topic: "{topic}"
    
    Source URL: {url}
    
    Instructions:
    - Write 3-6 concise bullet points capturing the key facts, claims, and data relevant to the topic.
    - Ignore navigation text, ads, or unrelated content.
    - Do not invent information that isn't in the text.
    - Keep it factual and neutral in tone.
    
    Page content:
    \"\"\"
    {trimmed}
    \"\"\"
    """

    try:
        client = _get_client()
        response = client.models.generate_content(
            model=_MODEL_NAME,
            contents=prompt,
        )
        summary = (response.text or "").strip()
    except Exception as exc:  # noqa: BLE001
        logger.error("Gemini summarization failed for %s: %s", url, exc)
        raise RuntimeError(f"Gemini summarization failed for {url}: {exc}") from exc

    if not summary:
        raise RuntimeError(f"Gemini returned an empty summary for {url}")

    return summary


def generate_final_report(topic: str, summaries: List[dict]) -> str:
    """
    Combine per-page summaries into one cohesive markdown research report.

    Args:
        topic: The overall research topic.
        summaries: List of dicts like {"url": str, "summary": str}.

    Returns:
        A full markdown-formatted report as a string (without the
        surrounding file boilerplate; report.py handles that).
    """
    if not summaries:
        raise ValueError("Cannot generate a report with zero summaries.")

    combined_sources = "\n\n".join(
        f"Source: {item['url']}\nSummary:\n{item['summary']}" for item in summaries
    )

    prompt = f"""You are a research assistant producing a final report on the topic:
"{topic}"

Below are summaries gathered from {len(summaries)} different web sources.
Synthesize them into a single, well-organized research report.

Requirements:
- Start with a short overview paragraph of the topic.
- Organize the body into clear sections with headings where it makes sense
  (e.g. by subtopic, or by comparing/contrasting viewpoints).
- Note any agreements or contradictions between sources.
- End with a short "Key Takeaways" bulleted list.
- Write in clear, neutral, professional prose. Do not fabricate facts
  beyond what the summaries support.
- Do not include a sources/references section yourself; that will be
  appended separately.

Summaries:
{combined_sources}
"""

    try:
        client = _get_client()
        response = client.models.generate_content(
            model=_MODEL_NAME,
            contents=prompt,
        )
        report_text = (response.text or "").strip()
    except Exception as exc:  # noqa: BLE001
        logger.error("Gemini final report generation failed: %s", exc)
        raise RuntimeError(f"Gemini final report generation failed: {exc}") from exc

    if not report_text:
        raise RuntimeError("Gemini returned an empty final report.")

    return report_text

Notice I'm passing the original research topic to the summarization prompt. This isn't just a generic "summarize this" — it's "summarize this in the context of what we're researching." That small detail makes the summaries way more useful.

Step 4: Saving a Beautiful Report

The final step is formatting everything into a nice markdown file. This is honestly the simplest part of the whole thing.

report.py

"""
report.py
Assembles the final report.md file: metadata header, the Gemini-generated
report body, and a sources list.
"""

from __future__ import annotations

import datetime
import logging
import os
from typing import List

logger = logging.getLogger(__name__)


def build_markdown(topic: str, report_body: str, sources: List[str]) -> str:
    """
    Assemble the full markdown document as a string.
    """
    timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")

    sources_section = "\n".join(f"{i}. {url}" for i, url in enumerate(sources, start=1))

    return f"""# Research Report: {topic}

*Generated on {timestamp}*

---

{report_body}

---

## Sources

{sources_section}
"""


def save_report(
    topic: str,
    report_body: str,
    sources: List[str],
    output_path: str = "report.md",
) -> str:
    """
    Build and write the markdown report to disk.

    Returns:
        The absolute path of the written file.
    """
    markdown = build_markdown(topic, report_body, sources)

    abs_path = os.path.abspath(output_path)
    with open(abs_path, "w", encoding="utf-8") as f:
        f.write(markdown)

    logger.info("Report written to %s", abs_path)
    return abs_path

We add a title, a timestamp, the synthesized report body, and a numbered list of all the sources we actually used. The "actually used" part is important — if a source failed to load or summarize, it doesn't appear here. The reader only sees what contributed to the report.

Putting It All Together

The main.py file orchestrates everything in sequence:

"""
main.py
Entry point for the AI Research Assistant.

Workflow:
  User -> Topic -> Search -> Top URLs -> Extract Text
       -> Gemini Summary (per URL) -> List of Summaries
       -> Gemini Final Report -> report.md
"""

from __future__ import annotations

import argparse
import logging
import sys

from search import search_topic
from scraper import extract_texts
from gemini import summarize_page, generate_final_report
from report import save_report

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("main")


def run(topic: str, num_results: int = 3, output_path: str = "report.md") -> str:
    """
    Run the full research pipeline for a given topic.

    Returns:
        Path to the generated report.md file.
    """
    logger.info("Step 1/5: Searching the web for: %s", topic)
    urls = search_topic(topic, num_results=num_results)
    for i, url in enumerate(urls, start=1):
        logger.info("  URL %d: %s", i, url)

    logger.info("Step 2/5: Extracting page text from %d URLs", len(urls))
    page_texts = extract_texts(urls)

    if not page_texts:
        raise RuntimeError(
            "Failed to extract usable text from any of the search results. "
            "Try a different topic or check your network connection."
        )

    logger.info("Step 3/5: Summarizing %d pages with Gemini", len(page_texts))
    summaries = []
    for url, text in page_texts.items():
        try:
            summary = summarize_page(url, text, topic)
            summaries.append({"url": url, "summary": summary})
            logger.info("  Summarized: %s", url)
        except RuntimeError as exc:
            logger.warning("  Skipping %s due to summarization error: %s", url, exc)

    if not summaries:
        raise RuntimeError("Gemini failed to summarize any of the extracted pages.")

    logger.info("Step 4/5: Generating final report with Gemini")
    report_body = generate_final_report(topic, summaries)

    logger.info("Step 5/5: Saving report to %s", output_path)
    final_path = save_report(
        topic=topic,
        report_body=report_body,
        sources=[item["url"] for item in summaries],
        output_path=output_path,
    )

    return final_path


def main() -> None:
    parser = argparse.ArgumentParser(
        description="AI Research Assistant - search, summarize, and report on a topic."
    )
    parser.add_argument(
        "topic",
        nargs="*",
        help="Research topic, e.g. 'Docker vs Podman'. If omitted, you'll be prompted.",
    )
    parser.add_argument(
        "-n",
        "--num-results",
        type=int,
        default=3,
        help="Number of search results to use (default: 3).",
    )
    parser.add_argument(
        "-o",
        "--output",
        default="report.md",
        help="Output markdown file path (default: report.md).",
    )
    args = parser.parse_args()

    topic = " ".join(args.topic).strip()
    if not topic:
        topic = input("Enter a research topic: ").strip()

    if not topic:
        print("No topic provided. Exiting.")
        sys.exit(1)

    try:
        path = run(topic, num_results=args.num_results, output_path=args.output)
    except Exception as exc:  # noqa: BLE001
        logger.error("Pipeline failed: %s", exc)
        sys.exit(1)

    print(f"\n[DONE] Report generated: {path}")


if __name__ == "__main__":
    main()

One detail I'm proud of: I wrapped each step in clear logging. When you run the tool, you can see exactly what's happening:

  • "Step 1/5: Searching the web..."

  • "Step 2/5: Extracting page text..."

  • And so on

This makes debugging way easier and gives you confidence that things are actually working.

What You Need to Get Started

The setup is deliberately minimal:

  1. Python 3.10+ (nothing exotic)

  2. A Gemini API key from Google AI Studio (they have a generous free tier)

  3. Four Python packages: google-genai, ddgs, trafilatura, and requests

pip install -r requirements.txt

Create a .env file with your API key:

GEMINI_API_KEY=your_key_here
GEMINI_MODEL=gemini-2.0-flash-lite

And you're ready to go.

Things I Learned Building This

Web scraping is still hard. Even with trafilatura, some sites just don't want to cooperate. That's why I built in graceful fallbacks and skipping logic.

Small AI models are underrated. You don't always need GPT-4 or Gemini Pro. For summarization tasks, the lighter models work great and cost a fraction of a cent per request.

Three sources is the sweet spot. For most topics, three good sources give you enough perspective without becoming redundant. But I made it configurable because sometimes you want more.

The "fail gracefully" pattern is underrated. Every function in this project either returns a meaningful value or a clear error. Nothing sneaky fails silently. This makes the whole pipeline way more reliable.

What's Next? (Stuff You Could Add)

This is intentionally minimal, but here are some ideas if you want to take it further:

  • Add date filtering — only use articles from the last year

  • Support multiple languages — search in French, summarize in English

  • Add a web interface — slap a simple Gradio or Streamlit UI on top

  • Save search history — so you can revisit past research topics

  • Add citation footnotes — show which source contributed which claim

Watch Me Build This Live

I made a complete walkthrough video where I build this from scratch and explain every decision along the way.

[Watch the video tutorial here — COMING SOON]

Wrapping Up

This little tool has genuinely changed how I research things. Instead of drowning in tabs, I just type a topic and get a clean report in seconds. It's not going to write a PhD thesis, but for getting up to speed on a new topic quickly? It's incredible.

The best part? The entire thing is about 250 lines of Python. There's no complex infrastructure, no vector databases, no LangChain abstractions. Just good old-fashioned piping data through a few well-defined steps.

The code is on GitHub. Clone it, break it, improve it, make it your own. That's what open source is about.

Got questions or ideas for features? Drop them in the comments below. I read every single one and I'm always looking for ways to make this better.

Happy researching! And remember — the best time to automate something is when you find yourself doing it at 11 PM on a Tuesday.

Resources

Video Tutorial

Step-by-step guide

Watch Tutorial

Source Code

Complete project

View Repository

Need Help?

Let our experts bring your project to life with professional development services.

Custom Development
Security & Performance
Explore Services

Tags

Python AI research assistantGemini API tutorialautomate web research with PythonDuckDuckGo search PythonAI report generator

Related Articles

Continue your learning journey

Frontend8 min

Advanced State Management with Pinia in Nuxt.js

Learn how to implement scalable state management in your Nuxt.js applications using Pinia.

Read more
Backend12 min

Building RESTful APIs with FastAPI and SQLAlchemy

Create high-performance REST APIs with automatic documentation using FastAPI and SQLAlchemy ORM.

Read more
DevOps10 min

Deploying Full-Stack Applications with Docker and CI/CD

Complete guide to containerizing and deploying your applications with automated pipelines.

Read more