Build a Citation-First RAG Assistant

Build a RAG app that answers uploaded notes with cited evidence.

Introduction

30 Second Summary

Interview notes are only useful when you can quickly find the right idea. A polished answer loses value when nobody can trace it back to the notes.

In this project, you will build a public citation-first retrieval-augmented generation assistant with Streamlit. It uses semantic search to find relevant passages before Gemini turns them into concise answers with visible citations.

What You'll Build

Your live app will turn an uploaded notes file plus a paraphrased interview question into a concise cited answer backed by inspectable evidence.

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

  • An upload flow for .txt or .md interview notes. You can see how many source chunks the app indexed.
  • A meaning-based search that returns the best three passages for a paraphrased question. Each result includes a similarity score.
  • A public cited assistant that answers from retrieved evidence at a shareable app URL. Its GitHub repository makes the full project inspectable.
  • Secret Mission: Add a four-question offline benchmark that reports retrieval hit rate at three.

Are there any prerequisites?

You should be comfortable writing Python functions plus using a terminal. Your existing Windows setup with Python, Visual Studio Code, Git, plus GitHub access is enough for the guided setup.

Before We Start

Before the hands-on work begins, take a moment to commit to what you are building. This gives every technical decision a clear purpose.

Prepare the Free AI Stack

The citation-first RAG demo will combine local retrieval with hosted generation. Its foundation must keep package behavior predictable.

You'll prepare Python in Visual Studio Code with an isolated virtual environment. You'll protect access to the Gemini Developer API before running a Streamlit starter app.

In this step, get ready to:
  • Prepare a reproducible Python environment.
  • Secure the AI credential and deployment account.
  • Prove the Streamlit starter app runs.
Prepare the Python environment

The project needs a supported Python release before its packages can work together. PowerShell gives you one place to verify that release before creating the isolated environment.

  • Press the Windows key to open search.
  • Type PowerShell into the search field.
  • Press Enter to open PowerShell.
  • Check your installed Python version by running this command:
python --version

What does this command do?

This command asks the active Python installation to print its version. The project supports Python 3.11 through 3.15.

✔️ I see a supported version

Your Python installation is ready for the pinned project packages. Continue with the project folder below.

ⓧ I see an unsupported version

A version outside the supported range can reject the pinned packages. Install a supported 64-bit release before creating the environment.

  • Open the official Python Windows downloads page.
  • Download a stable 64-bit Windows installer for Python 3.11 through 3.15.
  • Run the downloaded installer.
  • Complete the installation prompts.
  • Restart PowerShell after the installation finishes.
  • Verify the new Python version by running this command:
python --version

What should I see?

The version output should now fall between 3.11 and 3.15 inclusive.

ⓧ Command not found

PowerShell cannot find a Python installation on its current path. Install a supported Windows release before continuing.

  • Open the official Python Windows downloads page.
  • Download a stable 64-bit Windows installer for Python 3.11 through 3.15.
  • Run the downloaded installer.
  • Complete the installation prompts.
  • Restart PowerShell after the installation finishes.
  • Confirm PowerShell can now find Python by running this command:
python --version

What should I see?

PowerShell should print a Python version between 3.11 and 3.15 inclusive.

  • Create the project folder on your Desktop by running these commands:
Set-Location -Path ~/Desktop
New-Item -ItemType Directory -Path citation-first-rag-assistant
Set-Location -Path citation-first-rag-assistant

What do these commands do?

  • The first command moves PowerShell to your Desktop.
  • The second command creates the citation-first-rag-assistant folder.
  • The final command moves PowerShell into that folder.

You'll see the folder creation details in PowerShell. Your prompt now points to the citation-first-rag-assistant folder.

Project folder not created?

Check that PowerShell is pointing to your Desktop before creating the folder. Remove any spelling difference between the folder name in the final two commands.

Ask for help with the folder commands.

  • Open the current project folder in VS Code by running this command:
code .

What does this command do?

The code command starts VS Code. The dot tells it to open the folder PowerShell currently points to.

  • Select Yes, I trust the authors if VS Code shows the Workspace Trust dialog.
  • Click View in the VS Code top menu.
  • Select Terminal to display the integrated terminal.

VS Code now shows the citation-first-rag-assistant folder in Explorer. The integrated terminal starts inside the same folder.

VS Code did not open?

The terminal may not know the code command if VS Code was installed before your current PowerShell session began. Open VS Code through Windows search instead.

Use File followed by Open Folder to select the citation-first-rag-assistant folder from your Desktop.

Ask for help opening the project folder.

  • Create the virtual environment and activate it by running these commands in the VS Code terminal:
python -m venv .venv
.venv\Scripts\Activate.ps1

Why use a virtual environment?

The first command creates an isolated Python installation inside .venv. The second command makes that environment active for this terminal.

You'll see (.venv) at the beginning of the terminal prompt. That prefix confirms new package installations stay inside this project.

Virtual environment not active?

  • Confirm the terminal path ends with citation-first-rag-assistant.
  • Confirm the .venv folder appears in Explorer before activating it.
  • Use a PowerShell terminal because the activation path in this step targets PowerShell.

Ask for help activating the environment.

  • Select New File in the Explorer sidebar.
  • Type requirements.txt as the file name.
  • Press Enter.
  • Add the pinned dependencies by pasting this content:
streamlit==1.66.1
sentence-transformers==6.2.0
google-genai==2.34.0

What do these dependencies provide?

  • The streamlit==1.66.1 line pins the web app framework.
  • The sentence-transformers==6.2.0 line pins Sentence Transformers for local semantic retrieval.
  • The google-genai==2.34.0 line pins the Google Gen AI Python SDK used by the Gemini client.
  • Save requirements.txt.
  • Install the pinned dependencies into the active environment by running this command:
pip install -r requirements.txt

What does this command do?

The pip command reads every pinned package from requirements.txt. It installs those versions inside the active .venv environment.

This installation can take several minutes because the embedding package downloads supporting libraries. The terminal returns to the (.venv) prompt when installation finishes.

Dependency installation failed?

  • Confirm (.venv) still appears at the start of the terminal prompt.
  • Confirm requirements.txt contains the three pinned lines exactly.
  • Check your internet connection because the packages are downloaded during installation.

Ask for help with the installation output.

Before you test the installation, what do you expect a successful Streamlit environment to open in your browser?

  • Launch the Streamlit example app by running this command:
python -m streamlit hello

What does this command prove?

This command runs Streamlit through the Python interpreter inside .venv. A working example app confirms the pinned framework is installed in the correct environment.

You'll see a Streamlit example app open in your browser. The terminal stays busy while the local server runs.

  • Press Ctrl+C in the VS Code terminal to stop the example server.

That's the local foundation working. Your isolated environment can now launch a Streamlit app from the pinned installation.

Protect the API key and connect cloud accounts

The assistant needs a Gemini authorization key for answer generation. A local secrets file keeps that credential separate from code that later enters a public repository.

  • Select New File in the Explorer sidebar.
  • Type .gitignore as the file name.
  • Press Enter.
  • Add the protected paths by pasting this content:
.venv/
__pycache__/
*.pyc
.streamlit/secrets.toml

What does this file protect?

The file tells Git to exclude the virtual environment. It also excludes generated Python cache files.

The final line excludes the live Streamlit secrets file. That rule protects the Gemini key when the repository is created later.

  • Save .gitignore.
  • Confirm .gitignore appears in Explorer.

Hidden file not visible?

Confirm the file name begins with one dot. Check that VS Code did not append .txt to the name.

Ask for help checking the ignore file.

  • Select New Folder in the Explorer sidebar.
  • Type .streamlit as the folder name.
  • Press Enter.
  • Select the .streamlit folder in Explorer.
  • Select New File.
  • Type secrets.toml.example as the file name.
  • Press Enter.
  • Add the safe example configuration by pasting this content:
GEMINI_API_KEY = "your-api-key-here"

Why keep an example file?

The example records the required setting name without containing a live credential. Anyone who copies the repository can see how to configure the app safely.

  • Save .streamlit/secrets.toml.example.
  • Confirm the example file appears inside the .streamlit folder.

Example file in the wrong place?

The full path must be .streamlit/secrets.toml.example. Move the file into the .streamlit folder if it appears beside that folder.

Ask for help correcting the file location.

Creating an API key can feel sensitive. The live key stays on your machine inside a file Git ignores.

  • Open Google AI Studio in your browser.
  • Sign in with your Google account.
  • Accept the terms if Google AI Studio requests them.
  • Open the API Keys page.
  • Leave paid billing disconnected for this project.
  • Select Create API key to obtain a new authorization key.
  • Copy the generated key to your clipboard.
  • Return to the .streamlit folder in the VS Code Explorer.
  • Select New File.
  • Type secrets.toml as the file name.
  • Press Enter.
  • Add the local secret setting by pasting this content:
GEMINI_API_KEY = "your-api-key-here"

How does Streamlit use this file?

Streamlit reads values from the local secrets.toml file through its secrets API. The setting name must remain GEMINI_API_KEY so the later app code can find it.

  • Replace your-api-key-here with the authorization key you copied.
  • Save .streamlit/secrets.toml.
  • Close the editor tab that displays the live key.
  • Confirm secrets.toml appears inside the .streamlit folder.

Good work. Your live credential is available to the local app without becoming part of the project code.

Secret file not configured?

  • Confirm the file path is .streamlit/secrets.toml.
  • Confirm the setting name is exactly GEMINI_API_KEY.
  • Confirm the copied key remains inside double quotes.

Ask for help without sharing the key.

The first Community Cloud sign-in may display a GitHub authorization screen. That connection lets the deployment service access repositories you choose later.

  • Open Streamlit Community Cloud in your browser.
  • Select the GitHub sign-in option.
  • Approve the requested GitHub authorization if prompted.

You'll reach the Streamlit Community Cloud workspace after authentication. This confirms the deployment account is ready for the finished app.

Run the verified starter app

A small starter page tests the exact environment that the retrieval assistant will use. Seeing its message in the browser proves VS Code can run the pinned Streamlit installation.

  • Return to the project folder in the VS Code Explorer.
  • Select New File.
  • Type app.py as the file name.
  • Press Enter.
  • Add the starter interface by pasting this code:
import streamlit as st

st.write("Citation-first assistant setup complete")

What does this code do?

  • The import makes Streamlit's interface functions available through st.
  • The st.write() call places the setup confirmation on the page.
  • Save app.py.
  • Confirm the app.py tab no longer shows an unsaved-change indicator.

Before you run the app, what exact message do you expect the browser page to display?

  • Launch the starter app from the activated VS Code terminal by running this command:
python -m streamlit run app.py

What does this command prove?

Python starts the Streamlit server with app.py as its entry point. The command uses the packages installed inside the active virtual environment.

You'll see a browser page containing Citation-first assistant setup complete. The terminal remains active while the local app is running.

Starter message not visible?

  • Confirm the terminal prompt begins with (.venv).
  • Confirm app.py contains the import before the st.write() call.
  • Confirm the terminal is pointing to the citation-first-rag-assistant folder.

Ask for help with the starter app.

You now have the full free AI stack working locally. The pinned app runs while the live Gemini credential stays outside source control.

✔️ Awesome, I've got everything!

Great. Keep the starter app running while you confirm the browser message.

ⓧ I'd like to double check the full code

streamlit==1.66.1
sentence-transformers==6.2.0
google-genai==2.34.0
.venv/
__pycache__/
*.pyc
.streamlit/secrets.toml
GEMINI_API_KEY = "your-api-key-here"
import streamlit as st

st.write("Citation-first assistant setup complete")

Your reproducible environment is ready for real retrieval logic. Next, you'll build a keyword baseline that works on exact wording before a paraphrased question exposes its limit.

Build and Test the Keyword Baseline

Your Streamlit starter app proved that the pinned environment works. The app still cannot read interview notes or answer questions from them.

This step builds a keyword retrieval baseline that selects passages through shared words. You will compare its response to two ways of asking about the same idea.

Exact-word matching depends on the question sharing vocabulary with the notes. Testing that dependency gives you a visible baseline for the stronger retriever you build next.

In this step, get ready to:
  • Create sample interview notes for the assistant.
  • Add paragraph chunking plus keyword retrieval to the app.
  • Compare an exact-word question with a paraphrased question.
Create the sample notes

A fixed notes file gives both questions the same source material. Each paragraph covers one part of a production AI system.

  • Use the file controls in the left sidebar of VS Code to create sample_notes.txt inside the open project folder.

You should see sample_notes.txt in the project file list.

  • Add the interview notes to sample_notes.txt by pasting the content below:
Retrieval-augmented generation reduces unsupported claims by supplying selected source passages before response generation. The generator should answer from those passages, cite them, and admit when the evidence is insufficient.

Dense embeddings represent the semantic meaning of text as vectors. They make it possible to retrieve passages with similar meaning even when the query and passage use different words.

A RAG system should evaluate retrieval separately from answer generation. Retrieval hit rate at k checks whether the expected source appears among the top k passages returned for a labelled question.

Production AI applications need reproducible dependencies, clear error handling, and protected credentials. Secrets such as API keys must stay outside source control and should be configured through the deployment platform.

What do these notes cover?

The first passage explains how retrieval-augmented generation reduces unsupported claims. It also establishes the evidence that both test questions target.

The second passage introduces dense embeddings. The remaining passages cover retrieval evaluation plus production practices.

  • Save sample_notes.txt.
  • Confirm that the saved file contains four paragraphs separated by blank lines.

Notes appear as one paragraph?

Check that each passage has one empty line beneath it. The chunking function uses those blank lines to find paragraph boundaries.

Make sure the file is named sample_notes.txt without another extension added to the end.

Help me check why my sample notes are not separated into four paragraphs.

✔️ Awesome, I've got everything!

Your saved notes provide four source passages for the keyword baseline.

ⓧ I'd like to double check the full code

Retrieval-augmented generation reduces unsupported claims by supplying selected source passages before response generation. The generator should answer from those passages, cite them, and admit when the evidence is insufficient.

Dense embeddings represent the semantic meaning of text as vectors. They make it possible to retrieve passages with similar meaning even when the query and passage use different words.

A RAG system should evaluate retrieval separately from answer generation. Retrieval hit rate at k checks whether the expected source appears among the top k passages returned for a labelled question.

Production AI applications need reproducible dependencies, clear error handling, and protected credentials. Secrets such as API keys must stay outside source control and should be configured through the deployment platform.
Build the keyword retriever

The baseline needs two small helpers. One turns the uploaded document into paragraphs. The other returns paragraphs that share at least one word with the question.

  • Select all existing code in app.py.
  • Replace the selected starter code by pasting this first part of the baseline:
import streamlit as st


def chunk_text(text: str) -> list[str]:
    return [paragraph.strip() for paragraph in text.split("\n\n") if paragraph.strip()]


def keyword_retrieve(question: str, chunks: list[str]) -> list[str]:
    words = set(question.lower().split())
    return [chunk for chunk in chunks if words.intersection(chunk.lower().split())]


st.write("# Citation-First AI Interview Notes Assistant")
uploaded_file = st.file_uploader("Upload interview notes", type=["txt", "md"])

What does this code do?

  • The chunk_text() function splits the uploaded text at blank lines. It removes empty paragraphs plus surrounding whitespace.
  • The keyword_retrieve() function converts the question into a set of lowercase words. It keeps every chunk that shares at least one of those words.
  • The uploader accepts .txt files plus .md files. This keeps the baseline focused on plain text notes.
  • Save app.py.
  • Launch the updated app from the activated PowerShell terminal by running this command:
python -m streamlit run app.py

What does this command do?

This command starts the local app through the activated virtual environment. The terminal remains occupied while the app is running.

Your browser should display the title Citation-First AI Interview Notes Assistant. You should also see an uploader labelled Upload interview notes.

App does not show the uploader?

Confirm that the activated PowerShell prompt still shows the virtual environment. Check that you saved app.py before starting the app.

Help me troubleshoot why my Streamlit app does not display its file uploader.

The uploader now provides the document. The next block decodes that document as UTF-8 text. It then connects the chunks to a question field plus the visible results.

  • In app.py, place the cursor after the line that defines uploaded_file.
  • Add the upload processing plus question flow by pasting this code below that line:
if uploaded_file is not None:
    notes_text = uploaded_file.getvalue().decode("utf-8")
    chunks = chunk_text(notes_text)
    question = st.chat_input("Ask a question about these notes")

    if question:
        results = keyword_retrieve(question, chunks)
        st.write("## Keyword results")
        if results:
            for result in results:
                st.write(result)
        else:
            st.write("No useful keyword match found.")

How does the question flow work?

  • The upload block reads the file content. It passes that text to chunk_text().
  • The chat field returns the visitor's question. The app passes that question plus the chunks to keyword_retrieve().
  • The final branch displays every matching paragraph. It displays No useful keyword match found. when the result list is empty.
  • Save app.py.
  • Switch back to the running app in your browser.

The running app reloads with the saved code. You should still see the title plus the notes uploader.

  • Upload sample_notes.txt through the Upload interview notes control.

You should now see a chat field labelled Ask a question about these notes beneath the uploader.

Chat field does not appear?

Confirm that you uploaded sample_notes.txt after the latest app reload. The chat field only appears while an uploaded file is available.

Check that the code below if uploaded_file is not None: remains indented by four spaces.

Help me find why the chat input does not appear after I upload my notes.

✔️ Awesome, I've got everything!

Your saved app.py now accepts notes. It also exposes the keyword results through a chat question.

ⓧ I'd like to double check the full code

import streamlit as st


def chunk_text(text: str) -> list[str]:
    return [paragraph.strip() for paragraph in text.split("\n\n") if paragraph.strip()]


def keyword_retrieve(question: str, chunks: list[str]) -> list[str]:
    words = set(question.lower().split())
    return [chunk for chunk in chunks if words.intersection(chunk.lower().split())]


st.write("# Citation-First AI Interview Notes Assistant")
uploaded_file = st.file_uploader("Upload interview notes", type=["txt", "md"])

if uploaded_file is not None:
    notes_text = uploaded_file.getvalue().decode("utf-8")
    chunks = chunk_text(notes_text)
    question = st.chat_input("Ask a question about these notes")

    if question:
        results = keyword_retrieve(question, chunks)
        st.write("## Keyword results")
        if results:
            for result in results:
                st.write(result)
        else:
            st.write("No useful keyword match found.")
Test the baseline and expose the gap

The first check uses wording that appears directly in the first passage. This gives the baseline a fair chance to prove that its upload plus retrieval path works.

Before you submit this question, which passage do you expect the baseline to return? Keep your prediction in mind.

  • Enter What does retrieval-augmented generation supply before response generation? in the chat field.
  • Submit the question by pressing Enter.

You should see the Keyword results heading followed by the first notes passage. That working result proves the baseline can retrieve a paragraph when the wording overlaps.

The second check asks about the same idea using different words. This is the moment that tests whether literal overlap captures meaning.

Before you submit the paraphrase, do you expect the same passage to return? Hold onto that prediction.

  • Enter How do we stop an LLM from making things up? in the chat field.
  • Submit the question by pressing Enter.

You should see No useful keyword match found. This is the intended failure.

Why did the paraphrase fail?

The question describes unsupported claims as an LLM making things up. The relevant passage uses different words to express that idea.

The baseline compares split words through exact equality. It has no representation of the meaning shared by those two phrases.

You now have a working baseline plus a repeatable failure that exposes its limit. Next, you will replace literal word overlap with retrieval that compares meaning.

Replace Keywords with Semantic Retrieval

Your keyword baseline exposed the weakness of exact-word matching. The paraphrased question asks for the right idea without repeating the words from the relevant note.

This step replaces that baseline with semantic search powered by dense embeddings. Sentence Transformers will compare meaning so your Streamlit app can retrieve the relevant passage.

In this step, get ready to:
  • Move the reusable chunking pipeline into rag.py.
  • Rank note chunks with semantic similarity.
  • Display the three strongest sources with stable IDs and similarity scores.
Move the pipeline into rag.py

A reusable RAG pipeline keeps retrieval logic separate from the interface. The updated chunk_text function also gives every chunk a stable source ID.

  • In the file sidebar from earlier, create rag.py beside app.py.
  • Paste the following chunking pipeline into rag.py:
EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"


def chunk_text(text: str, max_words: int = 80, overlap: int = 15) -> list[dict[str, str]]:
    if max_words <= overlap:
        raise ValueError("max_words must be greater than overlap")

    paragraphs = [paragraph.strip() for paragraph in text.split("\n\n") if paragraph.strip()]
    chunks: list[dict[str, str]] = []
    step = max_words - overlap

    for paragraph_number, paragraph in enumerate(paragraphs, start=1):
        words = paragraph.split()
        for start in range(0, len(words), step):
            chunk_words = words[start : start + max_words]
            if not chunk_words:
                continue
            chunk_number = (start // step) + 1
            chunks.append({"id": f"P{paragraph_number}-C{chunk_number}", "text": " ".join(chunk_words)})
            if start + max_words >= len(words):
                break

    return chunks

What does this code do?

  • The EMBEDDING_MODEL constant keeps the selected model identifier in one reusable place.
  • The chunk_text function preserves paragraph boundaries while creating small overlapping windows.
  • The overlap protects context that sits near a chunk boundary.
  • IDs such as P1-C1 make each retrieved passage traceable to its original paragraph.
  • Save rag.py.
  • Confirm rag.py appears beside app.py in the file sidebar.

Can't find the new file?

Check that rag.py sits in the same project folder as app.py. A file created in another folder cannot be imported by this app.

Make sure the filename ends with .py.

Help me check why my app cannot find the new rag.py module.

Add semantic ranking

The retriever turns each chunk into an embedding vector. Semantic similarity then measures which vectors sit closest to the question before top-k retrieval keeps the three strongest results.

  • In rag.py, place your cursor below return chunks.
  • Add retrieve_chunks by pasting this code:
def retrieve_chunks(question: str, chunks: list[dict[str, str]], embedder, top_k: int = 3) -> list[dict[str, object]]:
    if not chunks:
        return []

    chunk_texts = [chunk["text"] for chunk in chunks]
    chunk_embeddings = embedder.encode(chunk_texts)
    question_embedding = embedder.encode([question])
    scores = embedder.similarity(question_embedding, chunk_embeddings)[0].tolist()
    ranked = sorted(enumerate(scores), key=lambda item: item[1], reverse=True)

    return [{"id": chunks[index]["id"], "text": chunks[index]["text"], "score": float(score)} for index, score in ranked[: min(top_k, len(ranked))]]

How does semantic ranking work?

  • The first encode call converts every chunk into a vector that represents its meaning.
  • The second encode call converts the question into the same vector space.
  • The similarity call assigns a score to every question-to-chunk comparison.
  • The final list keeps the highest scores while preserving each chunk's ID and text.
  • Save rag.py.
  • Confirm the editor shows retrieve_chunks beneath chunk_text.

Seeing errors in the retriever?

Check that retrieve_chunks starts after the final return chunks line. Incorrect indentation can place one function inside the other.

Compare the dictionary keys with id, text, and score.

Help me fix my retrieve_chunks function.

Connect the Streamlit app

The interface now needs to load the embedding model once and reuse it across reruns. Resource caching prevents the app from rebuilding the same model whenever a visitor submits a question.

  • Switch back to app.py from earlier.
  • Select all the existing code in app.py.
  • Paste the code below over the selection:
import streamlit as st
from sentence_transformers import SentenceTransformer

from rag import EMBEDDING_MODEL, chunk_text, retrieve_chunks


@st.cache_resource
def load_embedder() -> SentenceTransformer:
    return SentenceTransformer(EMBEDDING_MODEL)


st.write("# Citation-First AI Interview Notes Assistant")
uploaded_file = st.file_uploader("Upload interview notes", type=["txt", "md"], max_upload_size=2)

if uploaded_file is not None:
    notes_text = uploaded_file.getvalue().decode("utf-8")
    chunks = chunk_text(notes_text)
    embedder = load_embedder()
    question = st.chat_input("Ask a question about these notes")

    if question:
        retrieved = retrieve_chunks(question, chunks, embedder, top_k=3)
        st.write("## Retrieved evidence")
        for source_number, item in enumerate(retrieved, start=1):
            st.write(f"[Source {source_number}] {item['id']} | similarity {item['score']:.3f}")
            st.write(item["text"])

What changed in the app?

  • The imports replace the temporary keyword functions with the reusable pipeline from rag.py.
  • The load_embedder function loads the embedding model as a cached resource.
  • The uploader limits each notes file to 2 megabytes.
  • The result loop displays three ranked passages with source IDs and three-decimal similarity scores.
  • Save app.py.

✔️ Awesome, I've got everything!

Your reusable retriever and Streamlit interface now match the semantic search pipeline.

ⓧ I'd like to double check the full code

import streamlit as st
from sentence_transformers import SentenceTransformer

from rag import EMBEDDING_MODEL, chunk_text, retrieve_chunks


@st.cache_resource
def load_embedder() -> SentenceTransformer:
    return SentenceTransformer(EMBEDDING_MODEL)


st.write("# Citation-First AI Interview Notes Assistant")
uploaded_file = st.file_uploader("Upload interview notes", type=["txt", "md"], max_upload_size=2)

if uploaded_file is not None:
    notes_text = uploaded_file.getvalue().decode("utf-8")
    chunks = chunk_text(notes_text)
    embedder = load_embedder()
    question = st.chat_input("Ask a question about these notes")

    if question:
        retrieved = retrieve_chunks(question, chunks, embedder, top_k=3)
        st.write("## Retrieved evidence")
        for source_number, item in enumerate(retrieved, start=1):
            st.write(f"[Source {source_number}] {item['id']} | similarity {item['score']:.3f}")
            st.write(item["text"])

What should match in app.py?

Your complete app.py should import the retriever and cache one SentenceTransformer instance. Its result loop should display each source label before the corresponding passage.

EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"


def chunk_text(text: str, max_words: int = 80, overlap: int = 15) -> list[dict[str, str]]:
    if max_words <= overlap:
        raise ValueError("max_words must be greater than overlap")

    paragraphs = [paragraph.strip() for paragraph in text.split("\n\n") if paragraph.strip()]
    chunks: list[dict[str, str]] = []
    step = max_words - overlap

    for paragraph_number, paragraph in enumerate(paragraphs, start=1):
        words = paragraph.split()
        for start in range(0, len(words), step):
            chunk_words = words[start : start + max_words]
            if not chunk_words:
                continue
            chunk_number = (start // step) + 1
            chunks.append({"id": f"P{paragraph_number}-C{chunk_number}", "text": " ".join(chunk_words)})
            if start + max_words >= len(words):
                break

    return chunks


def retrieve_chunks(question: str, chunks: list[dict[str, str]], embedder, top_k: int = 3) -> list[dict[str, object]]:
    if not chunks:
        return []

    chunk_texts = [chunk["text"] for chunk in chunks]
    chunk_embeddings = embedder.encode(chunk_texts)
    question_embedding = embedder.encode([question])
    scores = embedder.similarity(question_embedding, chunk_embeddings)[0].tolist()
    ranked = sorted(enumerate(scores), key=lambda item: item[1], reverse=True)

    return [{"id": chunks[index]["id"], "text": chunks[index]["text"], "score": float(score)} for index, score in ranked[: min(top_k, len(ranked))]]

What should match in rag.py?

Your complete rag.py should contain the model identifier and both reusable functions. The returned results should include an ID, passage text, and floating-point score.

Before you run the updated app, do you think meaning alone can now put the unsupported-claims passage first?

  • Launch the updated app from the activated PowerShell environment by running this command:
python -m streamlit run app.py

What does this command do?

The command starts your updated Streamlit app with the packages from the activated virtual environment. Your browser then provides the upload field and chat input used for the semantic test.

The first query prepares the embedding model, so a longer pause than later queries is normal.

  • Upload sample_notes.txt in the browser.
  • Enter How do we stop an LLM from making things up? in the chat field.
  • Press Enter to submit the question.

You should see three retrieved sources with stable IDs and decimal similarity scores. The highest-ranked evidence should contain the passage about reducing unsupported claims by supplying selected source passages.

That closes the failure you recorded earlier. Your retriever now connects a paraphrased question to the correct evidence through meaning.

Relevant passage missing from the top result?

Confirm you uploaded sample_notes.txt from the project folder. Another notes file produces a different ranking.

Check that app.py calls retrieve_chunks with top_k=3.

Help me debug why semantic retrieval is not ranking the unsupported-claims passage first.

Your app can now find evidence through meaning instead of exact wording. Next, you will turn those retrieved passages into a concise answer with visible source citations.

Generate Answers That Cite Evidence

Your Streamlit app now uses semantic retrieval to find the right passage when a question uses different wording. The visible similarity scores show why each passage was selected.

Retrieval alone leaves visitors to interpret those passages themselves. This step uses Gemini to create a concise answer that points back to the supporting evidence.

In this step, get ready to:
  • Build a grounded prompt from the top three retrieved passages.
  • Generate an answer through the Gemini Interactions API.
  • Display cited answers while preserving evidence during generation failures.
Build the grounded prompt

Prompt grounding creates an evidence boundary for the model. Numbered source labels make the generated claims traceable to specific passages.

  • Select rag.py in the VS Code Explorer sidebar.
  • Replace the model constant at the top of rag.py with this configuration:
from google import genai

EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
GENERATION_MODEL = "gemini-3.8-flash"

What does this configuration do?

  • The genai import provides the client used for generation requests.
  • EMBEDDING_MODEL preserves the existing local retrieval model.
  • GENERATION_MODEL stores the gemini-3.8-flash model identifier in one place.
  • Locate the end of retrieve_chunks() in rag.py.
  • Add the prompt helper below retrieve_chunks() by pasting this code:
def build_grounded_prompt(question: str, retrieved: list[dict[str, object]]) -> str:
    evidence = "\n\n".join(f"[Source {number}] {item['text']}" for number, item in enumerate(retrieved, start=1))
    return f"""You are an AI interview study assistant.
Use only the evidence below to answer the question.
Cite every factual statement with the matching label, such as [Source 1].
If the evidence is insufficient, say that the notes do not contain enough information.
Keep the answer concise and useful for interview revision.

Question:
{question}

Evidence:
{evidence}
"""

How does the prompt stay grounded?

  • evidence assigns a numbered label to each retrieved passage.
  • The evidence instruction limits the answer to the supplied passages.
  • The citation instruction requires a source label for every factual statement.
  • The insufficiency instruction gives the model a safe response when the notes lack an answer.
  • Save rag.py.
  • Switch back to the browser tab containing your running app.
  • Refresh the browser page.
  • Upload sample_notes.txt through the file uploader.
  • Enter How do we stop an LLM from making things up? in the chat input.

You should still see the passage about reducing unsupported claims in the retrieved evidence. Good progress. Your grounded prompt now loads without disrupting semantic retrieval.

App stopped loading?

Check the opening triple quotes in build_grounded_prompt(). Confirm that the closing triple quotes appear after the evidence section.

Help me find the syntax problem in my grounded prompt helper.

Call Gemini through the Interactions API

The Google Gen AI Python SDK creates an authenticated client from the protected key. The generated text is available through interaction.output_text.

  • Locate the end of build_grounded_prompt() in rag.py.
  • Add the generation helper below build_grounded_prompt() by pasting this code:
def generate_answer(api_key: str, prompt: str) -> str:
    with genai.Client(api_key=api_key) as client:
        interaction = client.interactions.create(model=GENERATION_MODEL, input=prompt)
    return interaction.output_text or "The model returned no text."

What does the generation helper do?

  • genai.Client creates an authenticated client from the supplied key.
  • The context manager closes the client after the request.
  • client.interactions.create sends the grounded prompt to the configured model.
  • interaction.output_text exposes the generated answer as text.
  • Save rag.py.
  • Switch back to the browser tab containing your running app.
  • Refresh the browser page.

You should see the assistant title followed by the file uploader. This confirms that the completed generation module imports successfully.

Generation module not importing?

Check that generate_answer() starts after the closing triple quotes in build_grounded_prompt().

Help me diagnose why the generation helper prevents rag.py from importing.

Use these tabs to compare your completed retrieval and generation module.

✔️ Awesome, I've got everything!

Your rag.py file now supports semantic retrieval plus grounded generation. Keep the running app available for the interface update.

ⓧ I'd like to double check the full code

from google import genai

EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
GENERATION_MODEL = "gemini-3.8-flash"


def chunk_text(text: str, max_words: int = 80, overlap: int = 15) -> list[dict[str, str]]:
    if max_words <= overlap:
        raise ValueError("max_words must be greater than overlap")
    paragraphs = [paragraph.strip() for paragraph in text.split("\n\n") if paragraph.strip()]
    chunks: list[dict[str, str]] = []
    step = max_words - overlap
    for paragraph_number, paragraph in enumerate(paragraphs, start=1):
        words = paragraph.split()
        for start in range(0, len(words), step):
            chunk_words = words[start : start + max_words]
            if not chunk_words:
                continue
            chunk_number = (start // step) + 1
            chunks.append({"id": f"P{paragraph_number}-C{chunk_number}", "text": " ".join(chunk_words)})
            if start + max_words >= len(words):
                break
    return chunks


def retrieve_chunks(question: str, chunks: list[dict[str, str]], embedder, top_k: int = 3) -> list[dict[str, object]]:
    if not chunks:
        return []
    chunk_texts = [chunk["text"] for chunk in chunks]
    chunk_embeddings = embedder.encode(chunk_texts)
    question_embedding = embedder.encode([question])
    scores = embedder.similarity(question_embedding, chunk_embeddings)[0].tolist()
    ranked = sorted(enumerate(scores), key=lambda item: item[1], reverse=True)
    return [{"id": chunks[index]["id"], "text": chunks[index]["text"], "score": float(score)} for index, score in ranked[: min(top_k, len(ranked))]]


def build_grounded_prompt(question: str, retrieved: list[dict[str, object]]) -> str:
    evidence = "\n\n".join(f"[Source {number}] {item['text']}" for number, item in enumerate(retrieved, start=1))
    return f"""You are an AI interview study assistant.
Use only the evidence below to answer the question.
Cite every factual statement with the matching label, such as [Source 1].
If the evidence is insufficient, say that the notes do not contain enough information.
Keep the answer concise and useful for interview revision.

Question:
{question}

Evidence:
{evidence}
"""


def generate_answer(api_key: str, prompt: str) -> str:
    with genai.Client(api_key=api_key) as client:
        interaction = client.interactions.create(model=GENERATION_MODEL, input=prompt)
    return interaction.output_text or "The model returned no text."

How to use this reference

Compare this reference with your saved rag.py file. The two model constants plus both generation helpers should match exactly.

Connect cited answers to the interface

The generation request receives only the three retrieved passages. The uploaded file remains outside the prompt.

Free-tier Gemini content may be used to improve Google products. The interface keeps this privacy condition visible before a visitor uploads notes.

  • Select app.py in the VS Code Explorer sidebar.
  • Replace the existing from rag import line with this expanded import:
from rag import EMBEDDING_MODEL, build_grounded_prompt, chunk_text, generate_answer, retrieve_chunks

Why expand the import?

app.py now needs the prompt builder plus the generation helper. The existing retrieval functions remain available from the same module.

  • Find the title line near the top of app.py.
  • Replace the title line with these two lines:
st.write("# Citation-First AI Interview Notes Assistant")
st.write("Upload public or non-sensitive notes only. The app sends the three most relevant passages to Gemini, and free-tier content may be used to improve Google products.")

Why show this warning?

The message tells visitors which content is sent to Gemini. It also keeps the free-tier data-use condition visible before an upload.

  • Save app.py.
  • Switch back to the browser tab containing your running app.
  • Refresh the browser page.

You should see the privacy warning directly below the assistant title. The file uploader should remain underneath the warning.

Privacy warning missing?

Confirm that the second st.write call sits directly below the title. Save app.py before refreshing again.

Help me find why the privacy warning is missing from my app.

The completed answer flow handles unreadable uploads before retrieval starts. It also preserves the retrieved evidence when the generation request fails.

  • Locate the block starting with if uploaded_file is not None: in app.py.
  • Select that block through the end of the file.
  • Replace the selected block with this completed flow:
if uploaded_file is not None:
    try:
        notes_text = uploaded_file.getvalue().decode("utf-8")
    except UnicodeDecodeError:
        st.write("The file must use UTF-8 text encoding.")
    else:
        chunks = chunk_text(notes_text)
        if not chunks:
            st.write("The uploaded file does not contain readable text.")
        else:
            embedder = load_embedder()
            st.write(f"Indexed {len(chunks)} source chunks from {uploaded_file.name}.")
            question = st.chat_input("Ask a question about these notes")
            if question:
                retrieved = retrieve_chunks(question, chunks, embedder, top_k=3)
                prompt = build_grounded_prompt(question, retrieved)
                st.write("## Answer")
                try:
                    answer = generate_answer(st.secrets["GEMINI_API_KEY"], prompt)
                    st.write(answer)
                except Exception:
                    st.write("Generation is temporarily unavailable. Check the API key, free-tier quota, and deployment logs. The retrieved evidence is still shown below.")
                st.write("## Retrieved evidence")
                for source_number, item in enumerate(retrieved, start=1):
                    st.write(f"[Source {source_number}] {item['id']} | similarity {item['score']:.3f}")
                    st.write(item["text"])

How does the completed flow work?

  • The decoding guard displays a clear message for files outside UTF-8 text encoding.
  • The empty-content check prevents retrieval from running without readable notes.
  • build_grounded_prompt() receives the question plus the top three retrieved chunks.
  • The generation exception keeps the retrieved passages visible during service failures.
  • Save app.py.

Use these tabs to compare your completed interface before testing the cited answer.

✔️ Awesome, I've got everything!

Your interface now connects semantic retrieval to grounded Gemini generation. Continue to the final browser check.

ⓧ I'd like to double check the full code

import streamlit as st
from sentence_transformers import SentenceTransformer

from rag import EMBEDDING_MODEL, build_grounded_prompt, chunk_text, generate_answer, retrieve_chunks


@st.cache_resource
def load_embedder() -> SentenceTransformer:
    return SentenceTransformer(EMBEDDING_MODEL)


st.write("# Citation-First AI Interview Notes Assistant")
st.write("Upload public or non-sensitive notes only. The app sends the three most relevant passages to Gemini, and free-tier content may be used to improve Google products.")
uploaded_file = st.file_uploader("Upload interview notes", type=["txt", "md"], max_upload_size=2)

if uploaded_file is not None:
    try:
        notes_text = uploaded_file.getvalue().decode("utf-8")
    except UnicodeDecodeError:
        st.write("The file must use UTF-8 text encoding.")
    else:
        chunks = chunk_text(notes_text)
        if not chunks:
            st.write("The uploaded file does not contain readable text.")
        else:
            embedder = load_embedder()
            st.write(f"Indexed {len(chunks)} source chunks from {uploaded_file.name}.")
            question = st.chat_input("Ask a question about these notes")
            if question:
                retrieved = retrieve_chunks(question, chunks, embedder, top_k=3)
                prompt = build_grounded_prompt(question, retrieved)
                st.write("## Answer")
                try:
                    answer = generate_answer(st.secrets["GEMINI_API_KEY"], prompt)
                    st.write(answer)
                except Exception:
                    st.write("Generation is temporarily unavailable. Check the API key, free-tier quota, and deployment logs. The retrieved evidence is still shown below.")
                st.write("## Retrieved evidence")
                for source_number, item in enumerate(retrieved, start=1):
                    st.write(f"[Source {source_number}] {item['id']} | similarity {item['score']:.3f}")
                    st.write(item["text"])

How to use this reference

Compare this reference with your saved app.py file. Pay close attention to the nested upload checks plus the generation exception.

  • Switch back to the browser tab containing your running app.
  • Refresh the browser page.
  • Upload sample_notes.txt through the file uploader.

Before you ask the question, which source passage do you expect the answer to cite?

  • Enter Why are embeddings useful for search? in the chat input.

You should see a concise answer containing a label such as [Source 1]. Under Retrieved evidence, you should see the passage about finding similar meaning when the wording changes.

Generation unavailable or citations missing?

  • Confirm that GEMINI_API_KEY exists in the ignored local .streamlit/secrets.toml file.
  • Check that build_grounded_prompt() contains the source-label instruction.
  • Look below the generation failure message for the retrieved evidence.

Help me diagnose why my grounded answer is unavailable or missing citations.

You have completed the retrieval-to-generation loop. Your assistant now answers from selected evidence while showing the passages that support its response.

Next, you will publish the citation-first assistant through GitHub and Streamlit Community Cloud.

Publish the Live Portfolio Demo

Your assistant now retrieves relevant notes and produces a cited Gemini answer. The evidence remains visible even when generation is unavailable.

A local prototype cannot be inspected by recruiters. This step publishes the project through GitHub before deploying it with Streamlit Community Cloud.

In this step, get ready to:
  • Document the finished assistant in a reproducible README.
  • Publish the final files to a public GitHub repository.
  • Deploy the assistant to Streamlit Community Cloud.
Write the project README

A public repository needs enough context for someone else to understand the demo. Your README.md explains the result before documenting the local setup and architecture.

  • Switch back to Visual Studio Code from earlier.
  • Select the folder containing app.py in the Explorer sidebar.
  • Click the New File button in the Explorer toolbar.
  • Enter README.md as the file name.
  • Add the project summary by pasting this first section into README.md:
# Citation-First AI Interview Notes Assistant

A retrieval-augmented generation demo that answers questions from uploaded notes, cites its evidence, and exposes retrieval scores.

## What it demonstrates

- Paragraph-aware text chunking
- Local semantic embeddings
- Top-three similarity retrieval
- Grounded Gemini answers with source labels
- Protected secrets and free cloud deployment
- Offline retrieval evaluation

What does this section explain?

  • The opening sentence tells visitors what they can test in the app.
  • The capability list shows how retrieval and grounded generation work together.
  • The final item introduces the offline evaluation extension.
  • Save README.md in Visual Studio Code.

You should see the project title followed by six capabilities in the editor.

README formatting looks wrong?

Confirm the heading lines begin with the same number of hash characters shown above. Keep a blank line between each heading and its content.

Ask for help if the Markdown structure still looks wrong: Help me check the Markdown headings and list in my README.md file..

  • Place your cursor after the Offline retrieval evaluation bullet.
  • Press Enter twice to create a blank line.
  • Add the local setup guide by pasting this section:
## Local setup on Windows PowerShell

1. Confirm Python is supported:

   ```powershell
   python --version
   ```

2. Create and activate the environment:

   ```powershell
   python -m venv .venv
   .venv\Scripts\Activate.ps1
   ```

3. Install dependencies:

   ```powershell
   pip install -r requirements.txt
   ```

4. Create `.streamlit/secrets.toml` from `.streamlit/secrets.toml.example` and replace the placeholder with a Gemini API key. Never commit the real secrets file.

5. Run the app:

   ```powershell
   python -m streamlit run app.py
   ```

Why include local setup?

These instructions give another developer the same environment you tested. The example secrets file documents the required setting without exposing your real key.

  • Save README.md in Visual Studio Code.

You should see five numbered setup steps with PowerShell commands beneath the relevant steps.

Code fences not rendering correctly?

Check that each PowerShell command sits between matching lines of three backticks. Keep the indentation shown inside the numbered list.

Ask for help with the nested formatting: Help me fix the PowerShell code fences in my Markdown list..

  • Place your cursor after the final PowerShell code fence.
  • Press Enter twice to create a blank line.
  • Complete the README by pasting the architecture and privacy sections:
## Architecture

The uploaded notes are chunked locally. Sentence Transformers creates local embeddings, and semantic similarity selects the top three chunks. Only those retrieved chunks are placed into a grounded prompt for Gemini. The answer and its retrieved evidence are displayed together.

## Privacy

Use public or non-sensitive notes only. Gemini free-tier content may be used to improve Google products.

## Evaluation

After completing the Secret Mission, run:

```powershell
python eval.py
```

The script reports whether each expected passage appears in the top three retrieved results.

What does this section add?

  • The architecture section traces notes from local chunking to cited generation.
  • The privacy section limits the app to public or non-sensitive notes.
  • The evaluation section documents the optional retrieval benchmark.
  • Save README.md in Visual Studio Code.

You should now see the complete architecture, privacy, and evaluation sections beneath the local setup guide.

Missing a README section?

Compare your section order with the full-file reference below. Check for a missing heading if text appears under the wrong section.

Ask for a focused comparison: Help me find the missing or misplaced section in my README.md file..

✔️ Awesome, I've got everything!

Your README now documents the finished assistant and its reproducible setup.

ⓧ I'd like to double check the full code

# Citation-First AI Interview Notes Assistant

A retrieval-augmented generation demo that answers questions from uploaded notes, cites its evidence, and exposes retrieval scores.

## What it demonstrates

- Paragraph-aware text chunking
- Local semantic embeddings
- Top-three similarity retrieval
- Grounded Gemini answers with source labels
- Protected secrets and free cloud deployment
- Offline retrieval evaluation

## Local setup on Windows PowerShell

1. Confirm Python is supported:

   ```powershell
   python --version
   ```

2. Create and activate the environment:

   ```powershell
   python -m venv .venv
   .venv\Scripts\Activate.ps1
   ```

3. Install dependencies:

   ```powershell
   pip install -r requirements.txt
   ```

4. Create `.streamlit/secrets.toml` from `.streamlit/secrets.toml.example` and replace the placeholder with a Gemini API key. Never commit the real secrets file.

5. Run the app:

   ```powershell
   python -m streamlit run app.py
   ```

## Architecture

The uploaded notes are chunked locally. Sentence Transformers creates local embeddings, and semantic similarity selects the top three chunks. Only those retrieved chunks are placed into a grounded prompt for Gemini. The answer and its retrieved evidence are displayed together.

## Privacy

Use public or non-sensitive notes only. Gemini free-tier content may be used to improve Google products.

## Evaluation

After completing the Secret Mission, run:

```powershell
python eval.py
```

The script reports whether each expected passage appears in the top three retrieved results.

Your real API key and virtual environment must stay outside the public repository. The existing ignore rules protect both before Git stages any files.

  • Return to .gitignore in Visual Studio Code.
  • Compare its contents with these required exclusions:
.venv/
__pycache__/
*.pyc
.streamlit/secrets.toml

Why these exclusions matter

The .venv/ rule keeps downloaded dependencies out of source control. The final rule prevents your real Gemini key from entering the repository.

Your ignore file should contain all four lines. The local secrets file remains available to the app without becoming part of the commit.

Publish the code to GitHub

A Git commit records the exact files behind your working demo. The remote repository gives Streamlit Community Cloud a reproducible source to deploy.

  • Return to the activated PowerShell environment from earlier.
  • Stop the local app with Ctrl+C if it is still running.
  • Initialize the project on the main branch by running this command:
git init -b main

What does this command do?

The command creates local Git history for the existing project folder. It names the first branch main so the local branch matches the deployment branch.

You should see confirmation that an empty Git repository was initialized.

  • Stage the final files and create the project commit by running these commands:
git add .
git commit -m "Build citation-first RAG assistant"

What do these commands record?

The first command stages files that are not ignored. The second command records those staged files with a descriptive message.

The commit summary should list the project files. It should exclude .venv/ and .streamlit/secrets.toml.

Commit did not complete?

A first commit can require a Git author name and email. Follow the guidance printed by Git before running the commit command again.

Ask for help with the exact terminal output: Help me resolve my Git commit problem without staging ignored secrets..

The public repository becomes the source for your deployment. Keep it empty when you create it because your local project already includes the README and ignore file.

  • Return to your existing GitHub account in the browser.
  • Create a new public repository for the citation-first assistant.
  • Leave the README option unselected.
  • Leave the license option unselected.
  • Leave the .gitignore option unselected.
  • Create the empty repository.
  • Copy its remote repository URL.
  • Replace REMOTE-URL with the copied repository URL before running this command:
git remote add origin REMOTE-URL

What does this command connect?

The remote named origin connects your local project to the empty GitHub repository. Future pushes use that connection.

  • Verify the saved remote address by running this command:
git remote -v

What should this confirm?

The output should show your GitHub repository address for both remote operations. This confirms the local project points to the intended public repository.

  • Complete the browser sign-in flow if GitHub requests authentication during the first push.

Before you push, do you expect the ignored virtual environment and real secrets file to appear in the public repository?

  • Publish the main branch by running this command:
git push -u origin main

What does this push publish?

The command sends the committed main branch to GitHub. It also connects future local pushes to the same remote branch.

  • Refresh the public repository page in GitHub.
  • Confirm that README.md appears in the file list.
  • Confirm that .streamlit/secrets.toml does not appear in the file list.
  • Confirm that .venv/ does not appear in the file list.

That safeguard worked. Your public repository contains the reproducible project without publishing the real Gemini key or local environment.

Push did not reach GitHub?

Check that the remote address belongs to the empty repository you created. Complete any GitHub authentication flow before retrying the push.

Ask for help with the current remote and push output: Help me troubleshoot why my main branch did not reach GitHub..

Deploy the live app

Streamlit Community Cloud reads the repository files before installing the pinned dependencies. Its cloud Secrets field supplies the same setting that your ignored local secrets file provides.

  • Return to the signed-in Streamlit Community Cloud page from earlier.
  • Click Create app.
  • Select the public GitHub repository you just pushed.
  • Set the branch to main.
  • Set the entrypoint to app.py.
  • Open Advanced settings.
  • Select the same supported Python version that you verified locally.

Adding a credential to the deployment can feel risky. The cloud Secrets field keeps the real key outside the public repository.

  • Paste this setting into the Secrets field:
GEMINI_API_KEY = "your-api-key-here"

How does the cloud secret work?

The setting gives st.secrets["GEMINI_API_KEY"] the value used by app.py. The cloud stores the real value separately from GitHub.

  • Replace your-api-key-here with the real Gemini key from Google AI Studio.
  • Click Deploy.

The first deployment can spend several minutes installing dependencies. Keep the deployment page open while the app starts.

Deployment did not start?

Confirm that the selected branch is main and the entrypoint is app.py. Check that the cloud secret uses the same key name as the app.

Ask for help with the deployment logs: Help me diagnose my Streamlit Community Cloud deployment logs..

  • Copy the generated public URL here: your streamlit.app URL.

The first live question may respond more slowly while the embedding model loads. That startup work happens once for the cached resource.

Before you test the deployment, do you expect the paraphrased question to retrieve the unsupported-claims passage and produce visible citations?

  • Open your streamlit.app URL in a new browser tab.
  • Upload sample_notes.txt.
  • Enter How do we stop an LLM from making things up? in the chat input.
  • Press Enter to submit the question.

You should see a concise answer with [Source 1] style citations. The retrieved evidence should include the passage about reducing unsupported claims.

Live answer is unavailable?

Confirm that the deployed app indexed the uploaded notes before you submitted the question. Check the cloud secret if the evidence appears but generation shows the fallback message.

Ask for help without sharing the key: Help me diagnose why my deployed app retrieves evidence but cannot generate an answer..

You made the assistant shareable. Anyone with the public URL can now inspect the cited answer and the evidence behind it.

Secret mission

Add a Reproducible Retrieval Benchmark

Measure whether your retriever finds the expected evidence for four paraphrased questions. You will build an offline benchmark that reports each result plus retrieval hit rate at three.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep the project available, pause its deployment, or delete its resources. No paid resource is required under the current Gemini and Streamlit Community Cloud free tiers.

Resources you used:

  • A local .venv virtual environment.
  • A Gemini authorization key used through GEMINI_API_KEY.
  • A public GitHub repository containing the final project.
  • A Streamlit Community Cloud deployment containing the protected GEMINI_API_KEY secret.

Keep everything running

No cleanup action is needed. Choose this option while you are sharing the live assistant or improving its retrieval benchmark.

  • Leave the Streamlit Community Cloud deployment live.
  • Retain the Gemini authorization key.
  • Keep the public GitHub repository available.
  • Use the usage dashboard in Google AI Studio to confirm the project remains within its free-tier quota.

Pause - I'll come back to this later

Remove the Streamlit Community Cloud deployment while keeping the code needed to redeploy. This affects the public app only.

  • Return to Streamlit Community Cloud.
  • Select the deployed citation-first assistant.
  • Open the settings for the selected app.
  • Use the deployment removal option.
  • Approve the confirmation prompt.
  • Keep the public GitHub repository.
  • Retain the local project files.

The public streamlit.app URL should stop serving your assistant. Your repository remains ready for a future deployment.

Delete - I don't want to use this again

Deleting the hosted resources is permanent. Your local application files remain because the local cleanup removes only .venv.

Remove the Streamlit Community Cloud deployment:

  • Return to Streamlit Community Cloud.
  • Select the deployed citation-first assistant.
  • Open the settings for the selected app.
  • Use the deployment removal option.
  • Approve the confirmation prompt.

The public streamlit.app URL should stop serving the assistant. This confirms that the hosted deployment is gone.

Revoke the Gemini authorization key:

  • Return to the API Keys page in Google AI Studio.
  • Select the authorization key used by GEMINI_API_KEY.
  • Revoke the selected key.
  • Approve the confirmation prompt.

The authorization key should no longer be active. Requests that use the revoked key can no longer access Gemini.

Delete the public GitHub repository:

  • Return to the public GitHub repository.
  • Open the repository settings.
  • Locate the repository deletion control at the bottom of the settings page.
  • Choose the repository deletion option.
  • Enter the repository confirmation requested by GitHub.
  • Approve the final deletion prompt.

The repository URL should stop opening the project. This confirms that the public copy is gone.

Remove the local virtual environment:

  • Close any terminal using .venv.
  • Use Windows File Explorer to return to the local project folder from this project.
  • Remove the .venv folder.
  • Confirm that .venv is no longer listed beside the project files.

Your Python source files remain available locally. You can recreate the environment from requirements.txt whenever you return.

Nice Work!

Nice Work!

Outstanding work! You built a live citation-first retrieval-augmented generation assistant that answers questions from uploaded notes with traceable evidence.

You've learned how to:

  • Diagnose a visible paraphrase failure in keyword retrieval. Fix it with local semantic embeddings plus top-three similarity scoring.
  • Generate grounded Gemini answers with inline source labels. Keep the retrieved passages visible so every response can be inspected.
  • Store the reproducible project in a public GitHub repository. Deploy it through Streamlit Community Cloud with the Gemini API key protected as a separate secret.
  • Complete the optional Secret Mission by building an offline retrieval benchmark. Report hit rate at three across four labelled paraphrased questions.

Ready to quiz yourself?