Build a Grounded RAG Support Assistant

Build a React support assistant that grounds answers in policy documents.

Introduction

30 Second Summary

Customer-support answers can sound certain even when the real policy says something else. Without a source, a confident reply can still send a customer in the wrong direction.

In this project, you will build PolicyPal, a React support assistant powered by a Node.js backend. You will use Retrieval Augmented Generation (RAG) with Gemini File Search to compare unsupported model answers against responses grounded in Acme Gadgets' fictional policy.

What You'll Build

You will watch PolicyPal answer an Acme policy question from an indexed document with a source badge when Gemini returns a citation.

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

  • A responsive support chat that shows each question beside PolicyPal's answer.
  • A side-by-side answer comparison showing which response uses Acme's policy. Grounded replies display the source filename when Gemini returns a citation.
  • An in-app knowledge base control that indexes the fictional policy from the browser. A Delete index action removes the persistent store when you finish.
  • Secret Mission: A three-question stress test covering direct retrieval, paraphrased retrieval, and unsupported claims.

Do I need any experience before starting?

You need basic JavaScript knowledge plus familiarity with React components and state. Your computer also needs Node.js, Visual Studio Code, Git, plus Gemini API access through Google AI Studio.

Before We Start

Before any hands-on work begins, take a moment to define what PolicyPal needs to answer. This commitment keeps the project focused on fictional Acme Gadgets questions whose answers can be checked against company policy.

Set Up the Full-Stack Workspace

PolicyPal's frontend and backend need a dependable local workspace before they can work together. Its Gemini API key also needs to stay outside browser code and source control.

You'll verify the Node.js runtime before creating the PolicyPal workspace. You'll finish with a React starter connected to an Express health endpoint through Vite's proxy.

In this step, get ready to:
  • Confirm your Node.js version meets the project's requirement.
  • Create the workspace files and protect your Gemini API key.
  • Start the frontend and backend services locally.
Check the Node.js runtime

Vite needs Node.js 20.19 or newer for this project. Checking first prevents confusing installation failures later.

  • Press the Windows key to open Windows search.
  • Type PowerShell into the search field.
  • Press Enter to open PowerShell.
  • Check the installed Node.js version by running this command:
node --version

What does this command do?

The command prints the Node.js version that PowerShell can access. PolicyPal needs version 20.19 or newer.

✔️ I see version 20.19 or higher

Your Node.js runtime meets the project requirement. Keep PowerShell available while you create the workspace.

ⓧ I see an older version

Your current runtime is below the project's minimum. Update it before installing PolicyPal's dependencies.

  • Open the official Node.js download page in your browser.
  • Download a Windows installer for a supported Node.js release.
  • Run the installer with its default options.
  • Close PowerShell after the installation finishes.
  • Open a fresh PowerShell window through Windows search.
  • Check the updated version by running this command:
node --version

What should I see?

The fresh PowerShell window should report Node.js 20.19 or newer. Reopening PowerShell lets it detect the updated installation.

ⓧ Command not found

PowerShell cannot find a Node.js installation. Install it before creating the project.

  • Open the official Node.js download page in your browser.
  • Download a Windows installer for a supported Node.js release.
  • Run the installer with its default options.
  • Open a fresh PowerShell window through Windows search.
  • Confirm the installation by running this command:
node --version

What should I see?

PowerShell should now print a Node.js version. Continue once it reports 20.19 or newer.

Still seeing the wrong version?

Close every PowerShell window after updating Node.js. Open a fresh window so Windows reloads the command path.

If the old version remains, help me diagnose which Node.js installation PowerShell is using.

Create and protect the workspace

Visual Studio Code keeps the frontend files and backend files in one workspace. The browser receives only application data because the Gemini SDK stays in server.js.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field.
  • Press Enter to open Visual Studio Code.
  • Select File from the top menu.
  • Select Open Folder....
  • Select your Desktop in the folder dialog.
  • Select New Folder.
  • Name the new folder policy-pal.

The folder dialog now shows the empty policy-pal folder on your Desktop.

  • Select the policy-pal folder.
  • Click Select Folder.
  • Select Yes, I trust the authors if the Workspace Trust dialog appears.

You'll see POLICY-PAL at the top of the Explorer sidebar. This confirms that VS Code is using the correct workspace.

  • Select New Folder... in the Explorer sidebar.
  • Enter src as the folder name.
  • Select New Folder... again.
  • Enter docs as the folder name.

The Explorer sidebar now lists the empty src and docs folders inside policy-pal.

  • Select New File... in the Explorer sidebar.
  • Enter package.json as the file name.
  • Define the project scripts and pinned dependencies by pasting this code:
{
  "name": "policy-pal",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "server": "node server.js",
    "build": "vite build"
  },
  "dependencies": {
    "@google/genai": "2.31.0",
    "dotenv": "18.0.9",
    "express": "5.3.0",
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "6.1.2",
    "vite": "8.3.5"
  }
}

What does this file control?

  • The scripts start the Vite frontend, start the Express backend, or create a production build.
  • The dependencies provide React, Express, environment loading, and the server-side Google Gen AI SDK.
  • The exact version pins keep every learner on the same toolchain.
  • Save package.json by pressing Ctrl+S.

You'll see package.json in the Explorer sidebar without an unsaved-change dot on its editor tab.

Seeing a JSON warning?

Check that every key uses double quotes. Confirm the final dependency does not have a trailing comma.

If the warning remains, help me compare my package.json with the PolicyPal version.

  • Select View from the top menu.
  • Select Terminal.
  • Install the dependencies from the open policy-pal workspace by running:
npm install

What does this command do?

npm reads package.json and downloads its listed dependencies. It also creates a lock file that records the resolved package versions.

The terminal prints package activity while npm works. You'll see node_modules and package-lock.json appear in the Explorer sidebar when installation finishes.

Did the dependency install fail?

Confirm the terminal prompt is inside the policy-pal workspace. Check that your Node.js version is still 20.19 or newer.

If npm reports another problem, help me troubleshoot the PolicyPal dependency installation.

  • Create .gitignore with the Explorer sidebar's New File... action.
  • Protect generated files and local secrets by pasting this code:
node_modules
.env
dist

What does this file protect?

Git ignores installed packages, production output, and the local environment file. This prevents the Gemini API key in .env from entering source control.

  • Save .gitignore by pressing Ctrl+S.

You'll see .gitignore at the top level of the Explorer sidebar.

  • Create .env.example with the Explorer sidebar's New File... action.
  • Document the required environment variable by pasting this code:
GEMINI_API_KEY=your-api-key-here

Why keep an example file?

The example records the required GEMINI_API_KEY name without containing a live credential. It can remain in source control safely.

  • Save .env.example by pressing Ctrl+S.

You'll see both .env.example and .gitignore in the Explorer sidebar.

Your API key grants access to the Gemini API. Keep it private by pasting it only into the ignored local .env file.

✔️ I already have a Gemini API key

Keep the key ready for the local environment file. Do not paste it into chat, screenshots, or React files.

ⓧ I need to create a Gemini API key

Creating a key reveals a credential that can access your Gemini API project. Copy it into .env only.

  • Open Google AI Studio in your browser.
  • Sign in with your Google account.
  • Open the API Keys page from the dashboard.
  • Click Create API key.
  • Copy the generated key to your clipboard.
  • Close the key dialog after copying the credential.
  • Create .env with the Explorer sidebar's New File... action.
  • Add the local credential placeholder by pasting this line:
GEMINI_API_KEY=your-api-key-here

How does the server read this?

dotenv loads the value into process.env.GEMINI_API_KEY when the backend starts. The ignored .env file stays on your computer.

  • Select your-api-key-here inside .env.
  • Paste your Gemini API key over the selected placeholder.
  • Save .env by pressing Ctrl+S.

You'll see .env in the Explorer sidebar. Keep the editor closed before taking screenshots.

Is the key missing or exposed?

Confirm the variable name is exactly GEMINI_API_KEY. Confirm .gitignore contains a separate .env line.

If you pasted the key into another file, remove it there. Help me check that my Gemini API key is stored only in .env.

  • Create index.html with the Explorer sidebar's New File... action.
  • Add the browser entry document by pasting this code:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>PolicyPal</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

What does this file provide?

The root element gives React a place to render PolicyPal. The module script loads the frontend entry file.

  • Save index.html by pressing Ctrl+S.

You'll see index.html at the top level of the Explorer sidebar.

  • Create vite.config.js with the Explorer sidebar's New File... action.
  • Configure React and the local API proxy by pasting this code:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': 'http://localhost:3001',
    },
  },
});

Why use a Vite proxy?

The proxy forwards browser requests beginning with /api to the Express server. React can use relative API paths without adding a separate CORS dependency.

  • Save vite.config.js by pressing Ctrl+S.

You'll see vite.config.js beside package.json in the Explorer sidebar.

  • Create server.js with the Explorer sidebar's New File... action.
  • Build the protected backend and health endpoint by pasting this code:
import 'dotenv/config';
import express from 'express';
import { GoogleGenAI } from '@google/genai';

const apiKey = process.env.GEMINI_API_KEY;

if (!apiKey) {
  throw new Error('Add GEMINI_API_KEY to a local .env file before starting the server.');
}

const app = express();
const ai = new GoogleGenAI({ apiKey });
const port = 3001;

app.use(express.json());

app.get('/api/health', (request, response) => {
  response.json({ status: 'ok' });
});

app.listen(port, (error) => {
  if (error) throw error;
  console.log(`PolicyPal API running at http://localhost:${port}`);
});

What does this server do?

  • dotenv loads the private API key before the Gemini client is created.
  • Express parses JSON requests and returns a small health response at /api/health.
  • The Google Gen AI SDK is imported only in the backend file.
  • The server listens locally on port 3001.
  • Save server.js by pressing Ctrl+S.

You'll see server.js at the top level of the Explorer sidebar. The React files do not import the Gemini SDK.

Seeing import warnings in server.js?

Confirm the dependency installation finished successfully. Check that package.json contains "type": "module".

If an import remains unresolved, help me diagnose the PolicyPal server imports.

  • Select the src folder in the Explorer sidebar.
  • Create main.jsx with the New File... action.
  • Mount the React application by pasting this code:
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';

createRoot(document.getElementById('root')).render(<App />);

What does the entry file do?

The entry file finds the root element from index.html. It renders the App component with the project stylesheet.

  • Save src/main.jsx by pressing Ctrl+S.

You'll see main.jsx nested inside the src folder.

  • Create App.jsx inside the src folder.
  • Add the starter interface by pasting this code:
export default function App() {
  return (
    <main>
      <h1>PolicyPal</h1>
      <p>React + Node.js + Gemini File Search</p>
    </main>
  );
}

What does this component render?

The component renders the PolicyPal heading and its technology summary. This gives you a visible frontend result before adding chat behavior.

  • Save src/App.jsx by pressing Ctrl+S.

You'll see App.jsx beside main.jsx inside src.

  • Create styles.css inside the src folder.
  • Add the starter page spacing by pasting this code:
body {
  margin: 0;
  font-family: system-ui, sans-serif;
}

main {
  padding: 2rem;
}

What does this styling change?

The page uses the system font and removes the browser's default body margin. The main content receives consistent spacing from the window edges.

  • Save src/styles.css by pressing Ctrl+S.

The src folder now contains App.jsx, main.jsx, and styles.css.

Missing a starter file?

Expand the src folder in Explorer. Confirm each filename uses the same capitalization shown above.

If a file is in the wrong folder, drag it into src. Help me check my PolicyPal file structure.

Use these tabs to confirm every tracked starter file before starting the services.

✔️ Awesome, I've got everything!

Great. Double-check that every editor tab is saved. Keep .env private because it contains your live key.

ⓧ I'd like to double check the full code

GEMINI_API_KEY=your-api-key-here
node_modules
.env
dist
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>PolicyPal</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>
{
  "name": "policy-pal",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "server": "node server.js",
    "build": "vite build"
  },
  "dependencies": {
    "@google/genai": "2.31.0",
    "dotenv": "18.0.9",
    "express": "5.3.0",
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "6.1.2",
    "vite": "8.3.5"
  }
}
import 'dotenv/config';
import express from 'express';
import { GoogleGenAI } from '@google/genai';

const apiKey = process.env.GEMINI_API_KEY;

if (!apiKey) {
  throw new Error('Add GEMINI_API_KEY to a local .env file before starting the server.');
}

const app = express();
const ai = new GoogleGenAI({ apiKey });
const port = 3001;

app.use(express.json());

app.get('/api/health', (request, response) => {
  response.json({ status: 'ok' });
});

app.listen(port, (error) => {
  if (error) throw error;
  console.log(`PolicyPal API running at http://localhost:${port}`);
});
export default function App() {
  return (
    <main>
      <h1>PolicyPal</h1>
      <p>React + Node.js + Gemini File Search</p>
    </main>
  );
}
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';

createRoot(document.getElementById('root')).render(<App />);
body {
  margin: 0;
  font-family: system-ui, sans-serif;
}

main {
  padding: 2rem;
}
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': 'http://localhost:3001',
    },
  },
});
Start both local services

The frontend and backend run as separate processes. Two terminals keep both services active while Vite forwards API requests to Express.

Before you start them, what two success signals do you expect the terminals to show?

  • Start the Express backend in the existing VS Code terminal by running:
npm run server

What does this command start?

npm runs the backend script from package.json. The terminal stays occupied because the Express process keeps listening for requests.

You'll see PolicyPal API running at http://localhost:3001 in the terminal. Your backend is now listening for local requests.

Did the backend stop immediately?

A missing-key message means .env is absent or still contains the placeholder. Confirm the file is inside policy-pal.

If the port is unavailable, close another process that is using port 3001. Help me diagnose why the PolicyPal backend will not stay running.

  • Press Ctrl+Shift+` to create a second integrated terminal.
  • Start the Vite development server in the second terminal by running:
npm run dev

What does this command start?

npm runs the Vite development script from package.json. The terminal remains active so Vite can serve the React app and detect saved changes.

You'll see a local development address that uses http://localhost:5173. Both terminal processes should remain running.

Did the frontend fail to start?

Confirm the second terminal starts inside the policy-pal workspace. Check that the dependency installation created node_modules.

If Vite reports another problem, help me diagnose why the PolicyPal frontend will not start.

Before you check the browser, do you expect the frontend page or the health response to contain JSON?

  • Open http://localhost:5173 in your browser.

You'll see the PolicyPal heading above React + Node.js + Gemini File Search.

  • Open http://localhost:5173/api/health in a second browser tab.

You'll see {"status":"ok"} in the browser. This proves that Vite forwarded the /api/health request to Express.

That's the local connection working: React is rendering in the browser while Express answers requests behind Vite's proxy.

Your full-stack workspace is live with the API key protected behind the backend. Next, you'll turn the starter page into an ungrounded support chat and expose why company context matters.

Build an Ungrounded AI Support Chat

Your React frontend can now reach the Express backend through the Vite proxy. That connection gives PolicyPal a safe path to the Gemini API.

A plain model call produces a visible chat quickly. Its answer has no Acme policy context or policy source to prove the claim.

In this step, get ready to:
  • Create a backend route that validates policy questions.
  • Build the chat interaction in the browser.
  • Test an ungrounded answer that cannot cite Acme's policy.
Add the plain Gemini route

Browser questions need a controlled backend endpoint. This endpoint checks each question before passing it to Gemini.

  • In server.js, find the current port setting:
const port = 3001;

What are you changing?

The port already controls where Express listens. The model setting belongs beside it because both values configure the server.

  • Replace that line with these two lines:
const port = 3001;
const model = 'gemini-3.8-flash';

What does this setting do?

The model constant keeps the model identifier in one place. Every request in this project can now use the same configured model.

  • In server.js, find the line containing app.use(express.json());.
  • Add the validation and error helpers directly below that line by copying this code:
function isNonEmptyString(value) {
  return typeof value === 'string' && value.trim().length > 0;
}

function sendError(response, error) {
  console.error(error);
  response.status(500).json({
    error: error instanceof Error ? error.message : 'Unexpected server error.',
  });
}

What do these helpers do?

  • The isNonEmptyString() helper rejects missing values and questions containing only spaces.
  • The sendError() helper logs API failures on the server. It also gives the browser a readable error message.
  • Save server.js.
  • Return to the backend terminal from the previous step.
  • Restart the existing backend process with the same server command you used earlier.

You should see PolicyPal API running at http://localhost:3001. This confirms that both helpers load without a syntax error.

  • In server.js, find the existing app.get('/api/health' route.
  • Add the plain question route directly below the health route by copying this code:
app.post('/api/plain', async (request, response) => {
  const { question } = request.body ?? {};

  if (!isNonEmptyString(question)) {
    return response.status(400).json({ error: 'Question is required.' });
  }

  try {
    const interaction = await ai.interactions.create({
      model,
      input: `Answer briefly as a customer-support assistant. The company is fictional and you have not been given its official policies. User question: ${question.trim()}`,
    });

    return response.json({
      answer: interaction.output_text || 'No answer returned.',
      citations: [],
    });
  } catch (error) {
    return sendError(response, error);
  }
});

What does this route do?

  • The /api/plain route receives the question from the browser.
  • The validation check returns a readable client error when the question is empty.
  • The interaction sends only the cleaned question to Gemini. The browser never receives GEMINI_API_KEY.
  • The response includes an empty citations array because this request has no policy source.
  • Save server.js.
  • Return to the backend terminal from earlier.
  • Restart the existing backend process again.

You should see the PolicyPal API address again. The backend is now ready to receive plain support questions.

Backend fails to restart?

  • Check that model sits below the port constant.
  • Check that the new route appears before app.listen().
  • Compare every closing brace in app.post('/api/plain' with the code above.

Still stuck? Help me debug the PolicyPal plain route

Build the React chat interaction

The client now needs controlled state for the question and conversation. A request helper keeps every browser call pointed at Express.

  • In index.html, find the viewport metadata line.
  • Add this description metadata directly below it:
<meta name="description" content="PolicyPal compares ungrounded and policy-grounded Gemini answers." />

What does this metadata do?

The description records the purpose of the finished page in its document metadata. It gives browsers a concise summary without changing the visible interface.

  • In the VS Code Explorer sidebar, select src/styles.css.
  • Replace the existing stylesheet with this code:
:root {
  color: #e9eef7;
  background: #07111f;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}

* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input { font: inherit; }
.app-shell { min-height: 100vh; display: grid; place-items: center; padding: 32px 18px; background: #07111f; }
.app-card { width: min(960px, 100%); border: 1px solid #20334d; border-radius: 24px; background: #0b1829; }
.hero, .toolbar, .composer { padding: 28px; border-bottom: 1px solid #20334d; }
.eyebrow { color: #7dd3fc; font-weight: 800; }
.subtitle { color: #a9b8cc; }
.status, .message { padding: 12px; border: 1px solid #334a68; border-radius: 12px; }
.toolbar button, .composer button { padding: 10px 14px; border-radius: 12px; color: #dbeafe; background: #13243a; }
.active, .composer button { background: #2563eb !important; }
.conversation { min-height: 360px; padding: 24px 28px; }
.message { margin-bottom: 18px; background: #101f32; }
.message.user { margin-left: auto; background: #17345b; }
.composer-row { display: flex; gap: 10px; }
.composer input { width: 100%; padding: 13px 14px; border-radius: 12px; }

What does this styling create?

  • The page uses a dark background with light text for readable message cards.
  • The app card keeps the conversation within a centered layout.
  • User messages receive a separate background so each side of the conversation is easy to scan.
  • Save index.html.
  • Save src/styles.css.
  • Return to the browser running PolicyPal.

You should see the starter page switch to a dark background with light text. That visible change confirms the new stylesheet is active.

Still seeing the light starter page?

  • Check that you replaced the contents of src/styles.css.
  • Check that src/main.jsx still imports ./styles.css.

Need another pair of eyes? Help me find why the PolicyPal stylesheet is not loading

The new component is longer because it owns the complete request cycle. Assemble src/App.jsx in consecutive chunks so each responsibility stays readable.

  • In the VS Code Explorer sidebar, select src/App.jsx.
  • Replace the entire file starting with this import and request helper:
import { useState } from 'react';

async function requestJson(url, options = {}) {
  const response = await fetch(url, {
    headers: {
      'Content-Type': 'application/json',
    },
    ...options,
  });

  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.error || 'The request failed.');
  }

  return data;
}

What does the request helper do?

  • The helper sends JSON requests to relative Express endpoints such as /api/plain.
  • The Vite proxy forwards those requests to the backend.
  • A failed response becomes an error the chat interface can display.
  • Continue directly below the helper with the component state and the first half of the submit handler:
export default function App() {
  const [mode, setMode] = useState('plain');
  const [question, setQuestion] = useState('');
  const [messages, setMessages] = useState([]);
  const [busy, setBusy] = useState(false);
  const [storeName, setStoreName] = useState('');
  const [status, setStatus] = useState('Knowledge base not loaded');

  async function handleSubmit(event) {
    event.preventDefault();
    const trimmedQuestion = question.trim();

    if (!trimmedQuestion || busy) return;

    setQuestion('');
    setBusy(true);
    setMessages((current) => [
      ...current,
      { role: 'user', text: trimmedQuestion },
    ]);

What does this scaffold hold?

  • The state values track the selected mode and current question.
  • The state values also track conversation messages and request activity.
  • The submit handler trims the question before adding it to the visible conversation.

This scaffold intentionally leaves handleSubmit() open. Paste the next chunk directly below it before saving the file.

  • Complete the submit handler with the request and response logic:
    try {
      const data = await requestJson('/api/plain', {
        method: 'POST',
        body: JSON.stringify({ question: trimmedQuestion }),
      });

      setMessages((current) => [
        ...current,
        {
          role: 'assistant',
          text: data.answer,
          mode,
          citations: [],
        },
      ]);
    } catch (error) {
      setMessages((current) => [
        ...current,
        { role: 'assistant', text: error.message, mode, citations: [], error: true },
      ]);
    } finally {
      setBusy(false);
    }
  }

How does the request cycle work?

  • The request sends the cleaned question to /api/plain.
  • A successful answer becomes an assistant message with the current mode.
  • A failed request becomes a visible assistant error message.
  • The final state update releases the disabled controls after every outcome.

The request handler is now complete. The component remains open for its interface markup.

  • Continue below the submit handler with the header and mode control:
  return (
    <main className="app-shell">
      <section className="app-card">
        <header className="hero">
          <div>
            <p className="eyebrow">React + Node.js + Gemini File Search</p>
            <h1>PolicyPal</h1>
            <p className="subtitle">Compare a generic AI answer with one grounded in Acme Gadgets' policy.</p>
          </div>
          <span className="status">{status}</span>
        </header>
        <section className="toolbar">
          <button className={mode === 'plain' ? 'active' : ''} disabled={busy} onClick={() => setMode('plain')} type="button">Ungrounded</button>
        </section>

What does the header show?

  • The header identifies PolicyPal as the active application.
  • The status text shows whether a knowledge base exists.
  • The Ungrounded button displays the current answer mode.

The JSX remains open for the conversation and question form. Paste the final chunk directly below this section before saving.

  • Finish the component with the conversation list and question form:
        <section className="conversation">
          {messages.map((message, index) => (
            <article className={`message ${message.role}`} key={`${message.role}-${index}`}>
              <strong>{message.role === 'user' ? 'You' : 'PolicyPal'}</strong>
              {message.mode && <span> {message.mode === 'grounded' ? 'Grounded' : 'Ungrounded'}</span>}
              <p>{message.text}</p>
            </article>
          ))}
        </section>
        <form className="composer" onSubmit={handleSubmit}>
          <label htmlFor="question">Your question</label>
          <div className="composer-row">
            <input disabled={busy} id="question" onChange={(event) => setQuestion(event.target.value)} placeholder="Ask about returns, refunds, shipping, or warranty" value={question} />
            <button disabled={busy || !question.trim()} type="submit">{busy ? 'Working...' : 'Ask'}</button>
          </div>
        </form>
      </section>
    </main>
  );
}

How does the interface respond?

  • The conversation maps each stored message to a visible card.
  • Assistant messages show whether they came from Ungrounded or Grounded mode.
  • The controlled input keeps its text in React state.
  • The input and submit button become disabled while a request is active.
  • Save src/App.jsx.
  • Return to the PolicyPal page in your browser.

You should see the PolicyPal card with a knowledge-base status and an Ungrounded button. You should also see an empty conversation area above the question form.

Chat interface does not render?

  • Check that import { useState } from 'react'; is the first line of src/App.jsx.
  • Check that both halves of handleSubmit() appear before the return statement.
  • Check that the final closing brace remains at the bottom of the file.

Need help comparing the chunks? Help me repair my PolicyPal React component

Use this checkpoint to compare every project file with the expected state.

✔️ Awesome, I've got everything!

Great. Double-check that every edited file is saved before testing the chat.

ⓧ I'd like to double check the full code

GEMINI_API_KEY=your-api-key-here
node_modules
.env
dist
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="PolicyPal compares ungrounded and policy-grounded Gemini answers." />
    <title>PolicyPal</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>
{
  "name": "policy-pal",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "server": "node server.js",
    "build": "vite build"
  },
  "dependencies": {
    "@google/genai": "2.31.0",
    "dotenv": "18.0.9",
    "express": "5.3.0",
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "6.1.2",
    "vite": "8.3.5"
  }
}
import 'dotenv/config';
import express from 'express';
import { GoogleGenAI } from '@google/genai';

const apiKey = process.env.GEMINI_API_KEY;

if (!apiKey) {
  throw new Error('Add GEMINI_API_KEY to a local .env file before starting the server.');
}

const app = express();
const ai = new GoogleGenAI({ apiKey });
const port = 3001;
const model = 'gemini-3.8-flash';

app.use(express.json());

function isNonEmptyString(value) {
  return typeof value === 'string' && value.trim().length > 0;
}

function sendError(response, error) {
  console.error(error);
  response.status(500).json({
    error: error instanceof Error ? error.message : 'Unexpected server error.',
  });
}

app.get('/api/health', (request, response) => {
  response.json({ status: 'ok' });
});

app.post('/api/plain', async (request, response) => {
  const { question } = request.body ?? {};

  if (!isNonEmptyString(question)) {
    return response.status(400).json({ error: 'Question is required.' });
  }

  try {
    const interaction = await ai.interactions.create({
      model,
      input: `Answer briefly as a customer-support assistant. The company is fictional and you have not been given its official policies. User question: ${question.trim()}`,
    });

    return response.json({
      answer: interaction.output_text || 'No answer returned.',
      citations: [],
    });
  } catch (error) {
    return sendError(response, error);
  }
});

app.listen(port, (error) => {
  if (error) throw error;
  console.log(`PolicyPal API running at http://localhost:${port}`);
});
import { useState } from 'react';

async function requestJson(url, options = {}) {
  const response = await fetch(url, {
    headers: {
      'Content-Type': 'application/json',
    },
    ...options,
  });

  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.error || 'The request failed.');
  }

  return data;
}

export default function App() {
  const [mode, setMode] = useState('plain');
  const [question, setQuestion] = useState('');
  const [messages, setMessages] = useState([]);
  const [busy, setBusy] = useState(false);
  const [storeName, setStoreName] = useState('');
  const [status, setStatus] = useState('Knowledge base not loaded');

  async function handleSubmit(event) {
    event.preventDefault();
    const trimmedQuestion = question.trim();

    if (!trimmedQuestion || busy) return;

    setQuestion('');
    setBusy(true);
    setMessages((current) => [
      ...current,
      { role: 'user', text: trimmedQuestion },
    ]);

    try {
      const data = await requestJson('/api/plain', {
        method: 'POST',
        body: JSON.stringify({ question: trimmedQuestion }),
      });

      setMessages((current) => [
        ...current,
        {
          role: 'assistant',
          text: data.answer,
          mode,
          citations: [],
        },
      ]);
    } catch (error) {
      setMessages((current) => [
        ...current,
        { role: 'assistant', text: error.message, mode, citations: [], error: true },
      ]);
    } finally {
      setBusy(false);
    }
  }

  return (
    <main className="app-shell">
      <section className="app-card">
        <header className="hero">
          <div>
            <p className="eyebrow">React + Node.js + Gemini File Search</p>
            <h1>PolicyPal</h1>
            <p className="subtitle">Compare a generic AI answer with one grounded in Acme Gadgets' policy.</p>
          </div>
          <span className="status">{status}</span>
        </header>
        <section className="toolbar">
          <button className={mode === 'plain' ? 'active' : ''} disabled={busy} onClick={() => setMode('plain')} type="button">Ungrounded</button>
        </section>
        <section className="conversation">
          {messages.map((message, index) => (
            <article className={`message ${message.role}`} key={`${message.role}-${index}`}>
              <strong>{message.role === 'user' ? 'You' : 'PolicyPal'}</strong>
              {message.mode && <span> {message.mode === 'grounded' ? 'Grounded' : 'Ungrounded'}</span>}
              <p>{message.text}</p>
            </article>
          ))}
        </section>
        <form className="composer" onSubmit={handleSubmit}>
          <label htmlFor="question">Your question</label>
          <div className="composer-row">
            <input disabled={busy} id="question" onChange={(event) => setQuestion(event.target.value)} placeholder="Ask about returns, refunds, shipping, or warranty" value={question} />
            <button disabled={busy || !question.trim()} type="submit">{busy ? 'Working...' : 'Ask'}</button>
          </div>
        </form>
      </section>
    </main>
  );
}
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';

createRoot(document.getElementById('root')).render(<App />);
:root {
  color: #e9eef7;
  background: #07111f;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}

* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input { font: inherit; }
.app-shell { min-height: 100vh; display: grid; place-items: center; padding: 32px 18px; background: #07111f; }
.app-card { width: min(960px, 100%); border: 1px solid #20334d; border-radius: 24px; background: #0b1829; }
.hero, .toolbar, .composer { padding: 28px; border-bottom: 1px solid #20334d; }
.eyebrow { color: #7dd3fc; font-weight: 800; }
.subtitle { color: #a9b8cc; }
.status, .message { padding: 12px; border: 1px solid #334a68; border-radius: 12px; }
.toolbar button, .composer button { padding: 10px 14px; border-radius: 12px; color: #dbeafe; background: #13243a; }
.active, .composer button { background: #2563eb !important; }
.conversation { min-height: 360px; padding: 24px 28px; }
.message { margin-bottom: 18px; background: #101f32; }
.message.user { margin-left: auto; background: #17345b; }
.composer-row { display: flex; gap: 10px; }
.composer input { width: 100%; padding: 13px 14px; border-radius: 12px; }
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': 'http://localhost:3001',
    },
  },
});
Experience the ungrounded limitation

PolicyPal can now ask the model a fictional company question. The answer becomes useful only when you can verify whether it reflects the company's actual rules.

  • Return to the PolicyPal page at http://localhost:5173.
  • Confirm that Ungrounded is selected.
  • Enter Can I return opened earbuds after 45 days? in the Your question field.

Before you ask, do you think the model can prove that its answer matches Acme's policy?

  • Click Ask.
  • Wait for the request to finish.

You'll see your question followed by an AI response labelled Ungrounded. The wording can vary because the model has no Acme policy document.

The response has no policy source badge. That intended shortfall proves the chat cannot verify whether the answer reflects Acme's actual return rules.

No assistant response appears?

  • Check that both local services from the previous step are still running.
  • Check the backend terminal for a Gemini API error.
  • Check that GEMINI_API_KEY remains in the local .env file.

Still waiting for an answer? Help me trace the PolicyPal request from React to Express

You now have a working support chat that protects the API key behind Express. Next, you'll give PolicyPal a fictional policy knowledge base that its answers can search.

Index the Policy Knowledge Base

PolicyPal can already ask Gemini for an answer. That answer still has no authoritative Acme context.

This step turns Acme's fictional policy into searchable embeddings inside Gemini File Search. The indexed policy becomes the retrieval layer for your RAG flow.

In this step, get ready to:
  • Create the fictional Acme Gadgets policy document.
  • Build an Express route that indexes the policy in Gemini File Search.
  • Connect the React interface to the indexing route.
Create the fictional policy document

A grounded assistant needs an authoritative source containing the rules it should follow. This project uses fictional data because free-tier content may be used to improve Google's products.

  • Switch back to Visual Studio Code from earlier.
  • Select the docs folder in the Explorer sidebar.
  • Click the New File button at the top of the Explorer sidebar.
  • Enter policies.txt as the file name.
  • Add the fictional policy by pasting the following content into docs/policies.txt:
ACME GADGETS CUSTOMER POLICY

RETURNS
Customers may return unopened products within 30 calendar days of delivery. A receipt or order number is required. Opened earbuds cannot be returned for hygiene reasons unless they are defective.

REFUNDS
After Acme Gadgets receives and inspects an approved return, the refund is sent to the original payment method within 5 to 7 business days. Original express-shipping fees are not refundable.

SHIPPING
Standard shipping normally takes 3 to 5 business days after dispatch. Express shipping normally takes 1 to 2 business days after dispatch. Acme Gadgets ships only within the United States.

WARRANTY
Electronic products include a 12-month limited warranty covering manufacturing defects. The warranty does not cover accidental damage, liquid damage, loss, theft, or normal wear.

SUPPORT
Customer support is available Monday through Friday from 9:00 AM to 5:00 PM Pacific Time. Support is closed on federal holidays.

What does this document contain?

  • The returns section defines a 30-day window plus a hygiene exception for opened earbuds.
  • The refunds section defines when approved refunds reach the original payment method.
  • The remaining sections cover shipping times, warranty limits, and support hours.
  • Save docs/policies.txt.
  • Confirm the saved file shows all six uppercase section headings from RETURNS through SUPPORT.

Missing part of the policy?

Check that policies.txt sits inside the existing docs folder. Confirm that the file ends with the sentence about federal holidays.

Still stuck? Help me check my PolicyPal policy document

✔️ Awesome, I've got everything!

Your fictional policy is saved inside docs/policies.txt.

ⓧ I'd like to double check the full code

ACME GADGETS CUSTOMER POLICY

RETURNS
Customers may return unopened products within 30 calendar days of delivery. A receipt or order number is required. Opened earbuds cannot be returned for hygiene reasons unless they are defective.

REFUNDS
After Acme Gadgets receives and inspects an approved return, the refund is sent to the original payment method within 5 to 7 business days. Original express-shipping fees are not refundable.

SHIPPING
Standard shipping normally takes 3 to 5 business days after dispatch. Express shipping normally takes 1 to 2 business days after dispatch. Acme Gadgets ships only within the United States.

WARRANTY
Electronic products include a 12-month limited warranty covering manufacturing defects. The warranty does not cover accidental damage, liquid damage, loss, theft, or normal wear.

SUPPORT
Customer support is available Monday through Friday from 9:00 AM to 5:00 PM Pacific Time. Support is closed on federal holidays.
Create the File Search indexing route

Gemini File Search imports the document before splitting it into searchable pieces. The indexing route waits for that remote operation before returning the generated store name to React.

Why Gemini File Search?

Gemini File Search is a managed retrieval service built for RAG applications. It keeps this project focused on retrieval quality, grounding, and citations.

A custom vector database would add separate storage plus retrieval infrastructure. The managed store gives PolicyPal one persistent reference for the indexed policy.

  • Switch back to server.js from earlier.
  • Find app.use(express.json());.
  • Add the timing helper directly below that line.
  • Find the existing app.listen call near the bottom of server.js.
  • Add the indexing route directly above the app.listen call using the code below:
const sleep = (milliseconds) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

app.post('/api/index', async (request, response) => {
  let storeName = '';

  try {
    const store = await ai.fileSearchStores.create({
      config: {
        displayName: 'policy-pal-store',
        embeddingModel: 'models/gemini-embedding-2',
      },
    });

    storeName = store.name;

    let operation = await ai.fileSearchStores.uploadToFileSearchStore({
      file: 'docs/policies.txt',
      fileSearchStoreName: storeName,
      config: {
        displayName: 'policy-pal-policies.txt',
      },
    });

    while (!operation.done) {
      await sleep(2000);
      operation = await ai.operations.get({ operation });
    }

    return response.json({ storeName });
  } catch (error) {
    return sendError(response, error);
  }
});

What does this route do?

  • The store uses policy-pal-store as its display name.
  • The store uses models/gemini-embedding-2 to create searchable representations of the policy.
  • The upload gives the indexed document the display name policy-pal-policies.txt.
  • The polling loop checks the operation every two seconds until indexing finishes.
  • The response returns the generated storeName that future grounded requests need.

A store can be created before a later upload or indexing action fails. Cleaning up that incomplete store prevents failed attempts from leaving remote resources behind.

  • Inside the new catch block, find the line that returns sendError(response, error).
  • Add the following cleanup immediately above that return line:
    if (storeName) {
      await ai.fileSearchStores
        .delete({ name: storeName, config: { force: true } })
        .catch(() => {});
    }

How does failure cleanup work?

The cleanup runs only after the API has returned a non-empty store name. The forced deletion removes the incomplete store before Express sends the original failure back to the browser.

  • Save server.js.
  • Switch back to the VS Code terminal running the backend.
  • Press Ctrl+C to stop the current backend process.
  • Restart the backend by running this command:
npm run server

You should see PolicyPal API running at http://localhost:3001 again. This confirms that the new route loads without a JavaScript syntax error.

Backend not starting?

  • Confirm the sleep helper sits outside every route.
  • Check that the new POST /api/index route ends before the existing app.listen call.
  • Compare the braces around the try block with the full file below.

Need another pair of eyes? Help me debug my PolicyPal indexing route

✔️ Awesome, I've got everything!

The backend starts with the File Search indexing route ready for a browser request.

ⓧ I'd like to double check the full code

import 'dotenv/config';
import express from 'express';
import { GoogleGenAI } from '@google/genai';

const apiKey = process.env.GEMINI_API_KEY;

if (!apiKey) {
  throw new Error('Add GEMINI_API_KEY to a local .env file before starting the server.');
}

const app = express();
const ai = new GoogleGenAI({ apiKey });
const port = 3001;
const model = 'gemini-3.8-flash';

app.use(express.json());

const sleep = (milliseconds) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

function isNonEmptyString(value) {
  return typeof value === 'string' && value.trim().length > 0;
}

function sendError(response, error) {
  console.error(error);
  response.status(500).json({
    error: error instanceof Error ? error.message : 'Unexpected server error.',
  });
}

app.get('/api/health', (request, response) => {
  response.json({ status: 'ok' });
});

app.post('/api/plain', async (request, response) => {
  const { question } = request.body ?? {};

  if (!isNonEmptyString(question)) {
    return response.status(400).json({ error: 'Question is required.' });
  }

  try {
    const interaction = await ai.interactions.create({
      model,
      input: `Answer briefly as a customer-support assistant. The company is fictional and you have not been given its official policies. User question: ${question.trim()}`,
    });

    return response.json({
      answer: interaction.output_text || 'No answer returned.',
      citations: [],
    });
  } catch (error) {
    return sendError(response, error);
  }
});

app.post('/api/index', async (request, response) => {
  let storeName = '';

  try {
    const store = await ai.fileSearchStores.create({
      config: {
        displayName: 'policy-pal-store',
        embeddingModel: 'models/gemini-embedding-2',
      },
    });

    storeName = store.name;

    let operation = await ai.fileSearchStores.uploadToFileSearchStore({
      file: 'docs/policies.txt',
      fileSearchStoreName: storeName,
      config: {
        displayName: 'policy-pal-policies.txt',
      },
    });

    while (!operation.done) {
      await sleep(2000);
      operation = await ai.operations.get({ operation });
    }

    return response.json({ storeName });
  } catch (error) {
    if (storeName) {
      await ai.fileSearchStores
        .delete({ name: storeName, config: { force: true } })
        .catch(() => {});
    }

    return sendError(response, error);
  }
});

app.listen(port, (error) => {
  if (error) throw error;
  console.log(`PolicyPal API running at http://localhost:${port}`);
});
Connect indexing to the React interface

The generated File Search store name is the address React needs for future grounded questions. Saving that value in localStorage keeps the reference available after a browser refresh.

  • Switch back to src/App.jsx from earlier.
  • Find the current storeName state line.
  • Replace the existing storeName and status state lines with the state initializers plus loader below:
  const [storeName, setStoreName] = useState(
    () => localStorage.getItem('policyPalStore') || '',
  );
  const [status, setStatus] = useState(
    storeName ? 'Knowledge base ready' : 'Knowledge base not loaded',
  );

  async function loadKnowledgeBase() {
    setBusy(true);
    setStatus('Indexing the policy document...');

    try {
      const data = await requestJson('/api/index', { method: 'POST' });
      localStorage.setItem('policyPalStore', data.storeName);
      setStoreName(data.storeName);
      setMode('grounded');
      setStatus('Knowledge base ready');
    } catch (error) {
      setStatus(error.message);
    } finally {
      setBusy(false);
    }
  }

How does React remember the store?

  • The state initializer reads policyPalStore when the component first loads.
  • The loader sends a POST request to /api/index without exposing the Gemini API key.
  • A successful response saves the generated name in browser storage plus React state.
  • The successful request selects grounded mode and changes the status to Knowledge base ready.

The mode controls need to show when grounded mode becomes available. The grounded button stays disabled until React has a File Search store name.

  • Find the existing toolbar section inside the returned JSX.
  • Replace that section with the two mode controls below:
        <section className="toolbar" aria-label="Answer controls">
          <div className="mode-switcher">
            <button
              className={mode === 'plain' ? 'active' : ''}
              disabled={busy}
              onClick={() => setMode('plain')}
              type="button"
            >
              Ungrounded
            </button>
            <button
              className={mode === 'grounded' ? 'active' : ''}
              disabled={busy || !storeName}
              onClick={() => setMode('grounded')}
              type="button"
            >
              Grounded
            </button>
          </div>
        </section>

What do the mode controls prove?

The disabled grounded button gives the store state a visible effect. React enables the control as soon as indexing returns a usable store name.

  • Save src/App.jsx.
  • Return to the PolicyPal page in your browser.
  • Refresh the page.

You should see an enabled Ungrounded button beside a disabled Grounded button.

Grounded button missing?

Confirm the replacement still sits inside the app-card section. Check the browser page again after saving src/App.jsx.

Need help checking the JSX? Help me fix my PolicyPal mode controls

PolicyPal still needs a control that starts indexing. Hiding that control after success prevents an accidental second store from being created.

  • Inside the updated toolbar section, find the closing tag for mode-switcher.
  • Add the store action immediately below that closing tag:
          <div className="store-actions">
            {!storeName && (
              <button
                className="secondary"
                disabled={busy}
                onClick={loadKnowledgeBase}
                type="button"
              >
                Load knowledge base
              </button>
            )}
          </div>

How does the load action behave?

The button calls loadKnowledgeBase() only while no stored name exists. The busy state disables the control while Express creates and indexes the remote store.

  • Save src/App.jsx.
  • Refresh the PolicyPal page.

You should now see the Load knowledge base button beside the mode controls.

Before you click, what do you expect the status and Grounded control to show after indexing finishes?

  • Click Load knowledge base.

Indexing includes a remote upload plus embedding work. The status stays on Indexing the policy document... while those stages finish.

You should see Knowledge base ready when the request succeeds. The Grounded button becomes active while the load button disappears.

  • Refresh the PolicyPal page once more.

You should still see Knowledge base ready after the refresh. The Grounded button remains available because React restored the store name from policyPalStore.

Knowledge base not becoming ready?

  • Check the backend terminal for the error returned by Gemini File Search.
  • Confirm that docs/policies.txt exists inside the policy-pal folder.
  • Confirm the backend remains running while the browser sends the indexing request.

Still blocked? Help me debug the PolicyPal indexing request

✔️ Awesome, I've got everything!

PolicyPal remembers the ready File Search store after a browser refresh.

ⓧ I'd like to double check the full code

import { useState } from 'react';

async function requestJson(url, options = {}) {
  const response = await fetch(url, {
    headers: {
      'Content-Type': 'application/json',
    },
    ...options,
  });

  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.error || 'The request failed.');
  }

  return data;
}

export default function App() {
  const [mode, setMode] = useState('plain');
  const [question, setQuestion] = useState('');
  const [messages, setMessages] = useState([]);
  const [busy, setBusy] = useState(false);
  const [storeName, setStoreName] = useState(
    () => localStorage.getItem('policyPalStore') || '',
  );
  const [status, setStatus] = useState(
    storeName ? 'Knowledge base ready' : 'Knowledge base not loaded',
  );

  async function loadKnowledgeBase() {
    setBusy(true);
    setStatus('Indexing the policy document...');

    try {
      const data = await requestJson('/api/index', { method: 'POST' });
      localStorage.setItem('policyPalStore', data.storeName);
      setStoreName(data.storeName);
      setMode('grounded');
      setStatus('Knowledge base ready');
    } catch (error) {
      setStatus(error.message);
    } finally {
      setBusy(false);
    }
  }

  async function handleSubmit(event) {
    event.preventDefault();
    const trimmedQuestion = question.trim();

    if (!trimmedQuestion || busy) return;

    setQuestion('');
    setBusy(true);
    setMessages((current) => [
      ...current,
      { role: 'user', text: trimmedQuestion },
    ]);

    try {
      const data = await requestJson('/api/plain', {
        method: 'POST',
        body: JSON.stringify({ question: trimmedQuestion }),
      });

      setMessages((current) => [
        ...current,
        {
          role: 'assistant',
          text: data.answer,
          mode,
          citations: [],
        },
      ]);
    } catch (error) {
      setMessages((current) => [
        ...current,
        { role: 'assistant', text: error.message, mode, citations: [], error: true },
      ]);
    } finally {
      setBusy(false);
    }
  }

  return (
    <main className="app-shell">
      <section className="app-card">
        <header className="hero">
          <div>
            <p className="eyebrow">React + Node.js + Gemini File Search</p>
            <h1>PolicyPal</h1>
            <p className="subtitle">Compare a generic AI answer with one grounded in Acme Gadgets' policy.</p>
          </div>
          <span className="status">{status}</span>
        </header>
        <section className="toolbar" aria-label="Answer controls">
          <div className="mode-switcher">
            <button
              className={mode === 'plain' ? 'active' : ''}
              disabled={busy}
              onClick={() => setMode('plain')}
              type="button"
            >
              Ungrounded
            </button>
            <button
              className={mode === 'grounded' ? 'active' : ''}
              disabled={busy || !storeName}
              onClick={() => setMode('grounded')}
              type="button"
            >
              Grounded
            </button>
          </div>
          <div className="store-actions">
            {!storeName && (
              <button
                className="secondary"
                disabled={busy}
                onClick={loadKnowledgeBase}
                type="button"
              >
                Load knowledge base
              </button>
            )}
          </div>
        </section>
        <section className="conversation">
          {messages.map((message, index) => (
            <article className={`message ${message.role}`} key={`${message.role}-${index}`}>
              <strong>{message.role === 'user' ? 'You' : 'PolicyPal'}</strong>
              {message.mode && <span> {message.mode === 'grounded' ? 'Grounded' : 'Ungrounded'}</span>}
              <p>{message.text}</p>
            </article>
          ))}
        </section>
        <form className="composer" onSubmit={handleSubmit}>
          <label htmlFor="question">Your question</label>
          <div className="composer-row">
            <input disabled={busy} id="question" onChange={(event) => setQuestion(event.target.value)} placeholder="Ask about returns, refunds, shipping, or warranty" value={question} />
            <button disabled={busy || !question.trim()} type="submit">{busy ? 'Working...' : 'Ask'}</button>
          </div>
        </form>
      </section>
    </main>
  );
}

That is the retrieval foundation in place. Next, you will send grounded questions through the indexed policy and display the evidence Gemini returns.

Ground Answers and Display Sources

Your Gemini File Search store now holds searchable embeddings for the Acme Gadgets policy. PolicyPal also retains its original ungrounded chat path for comparison.

Indexing prepares the policy for retrieval. This step completes the RAG path by routing grounded questions through Express. You will then present the answer evidence in React.

In this step, get ready to:
  • Query the indexed policy through File Search.
  • Display grounded answers with citation evidence.
  • Compare both answer modes before checking the production build.
Query the indexed policy

Grounded responses can include text inside model-output blocks. They can also include citation annotations that identify the retrieved file.

The backend needs to collect both parts before React can display them. It also needs to remove duplicate citations from the response.

  • Switch back to server.js in Visual Studio Code.
  • Place this response parser below sendError():
function collectGroundedResponse(interaction) {
  let answer = '';
  const citations = [];

  for (const step of interaction.steps ?? []) {
    if (step.type !== 'model_output') continue;

    for (const contentBlock of step.content ?? []) {
      if (contentBlock.type !== 'text') continue;

      answer += contentBlock.text ?? '';

      for (const annotation of contentBlock.annotations ?? []) {
        if (annotation.type !== 'file_citation') continue;

        citations.push({
          fileName: annotation.file_name || 'Policy document',
          source: annotation.source || '',
        });
      }
    }
  }
}

How does the parser work?

  • The outer loop checks each interaction step for model output.
  • The inner loop joins every text block into one answer.
  • The annotation loop keeps only file_citation entries because those entries connect the answer to the policy document.
  • Place the following deduplication and return logic before the final closing brace of collectGroundedResponse():
  const uniqueCitations = citations.filter(
    (citation, index, allCitations) =>
      index ===
      allCitations.findIndex(
        (item) =>
          item.fileName === citation.fileName && item.source === citation.source,
      ),
  );

  return {
    answer: answer.trim() || interaction.output_text || 'No answer returned.',
    citations: uniqueCitations,
  };

Why filter the citations?

A response can reference the same file more than once. The filter keeps one badge for each unique filename and source combination.

The answer fallback also handles a response where the model text sits in interaction.output_text instead of the collected blocks.

  • Save server.js.
  • Return to the backend terminal from earlier.
  • Stop the running backend process.
  • Restart the backend using the server command from Step 1.

You should see PolicyPal API running at http://localhost:3001. This confirms the parser has valid JavaScript syntax.

Does the backend stop during startup?

Check that the deduplication block sits inside collectGroundedResponse(). Its closing brace must remain after the new return object.

Still stuck? Help me check the braces in collectGroundedResponse().

The parser shapes a grounded response after Gemini finishes. The next route supplies the stored File Search name so retrieval can happen in the first place.

  • Place this route below POST /api/index in server.js:
app.post('/api/ask', async (request, response) => {
  const { question, storeName } = request.body ?? {};

  if (!isNonEmptyString(question)) {
    return response.status(400).json({ error: 'Question is required.' });
  }

  if (!isNonEmptyString(storeName)) {
    return response.status(400).json({ error: 'Load the knowledge base first.' });
  }

  try {
    const interaction = await ai.interactions.create({
      model,
      input: `Answer only from the retrieved Acme Gadgets policy. If the policy does not contain the answer, say "I can't find that in the policy." Question: ${question.trim()}`,
      tools: [
        {
          type: 'file_search',
          file_search_store_names: [storeName],
        },
      ],
    });

    return response.json(collectGroundedResponse(interaction));
  } catch (error) {
    return sendError(response, error);
  }
});

What does the grounded route do?

  • The validation rejects an empty question or missing store name before calling Gemini.
  • The file_search tool limits retrieval to the store selected by React.
  • The prompt confines answers to the retrieved Acme policy. Unsupported questions receive the defined not-found response.
  • The parser returns answer text plus unique citation data as JSON.
  • Save server.js.
  • Return to the backend terminal.
  • Stop the running backend process.
  • Restart the backend using the server command from Step 1.

You should see the PolicyPal API startup message again. The backend can now accept grounded questions at /api/ask.

Does the grounded route fail to start?

Confirm that POST /api/ask appears after the complete indexing route. A new route placed inside POST /api/index causes mismatched braces.

Still stuck? Help me place the grounded Express route correctly.

Present grounded evidence

The browser already knows the active mode and the stored File Search name. Its submit handler now needs to choose the matching backend route.

Each assistant message also carries its mode and citation array. That message-level evidence keeps earlier grounded answers accurate after the learner switches modes.

  • Switch back to src/App.jsx.
  • Find the early return inside handleSubmit() that checks the trimmed question and busy state.
  • Place this grounded-mode guard immediately below that return:
    if (mode === 'grounded' && !storeName) {
      setStatus('Load the knowledge base before using Grounded mode.');
      return;
    }

Why guard Grounded mode?

A grounded request requires a File Search store name. The guard keeps the failure visible in the status area if the browser has no store reference.

  • Find the request inside the try block of handleSubmit().
  • Replace the request and successful assistant-message update with this mode-aware version:
      const url = mode === 'grounded' ? '/api/ask' : '/api/plain';
      const payload =
        mode === 'grounded'
          ? { question: trimmedQuestion, storeName }
          : { question: trimmedQuestion };

      const data = await requestJson(url, {
        method: 'POST',
        body: JSON.stringify(payload),
      });

      setMessages((current) => [
        ...current,
        {
          role: 'assistant',
          text: data.answer,
          mode,
          citations: data.citations || [],
        },
      ]);

How does mode selection work?

  • Ungrounded mode sends only the question to /api/plain.
  • Grounded mode sends the question plus storeName to /api/ask.
  • The saved message records the selected mode and any returned citations so the conversation can label its evidence.
  • Save src/App.jsx.
  • Return to PolicyPal in the browser.
  • Select Grounded in the mode switcher.
  • Enter Can I return opened earbuds after 45 days? in the question field.
  • Click Ask.

You should receive an answer based on the indexed policy. The message carries a Grounded label.

Does the grounded request fail?

Confirm that the status still reads Knowledge base ready. A missing browser reference prevents the app from sending the store name.

Still stuck? Help me trace the grounded request from React to Express.

The grounded answer is now visible. The source area turns its citation annotations into filename badges while preserving an honest fallback when Gemini returns no annotation.

  • Find <p>{message.text}</p> inside the assistant-message rendering code.
  • Place this source area immediately below it:
                {message.role === 'assistant' &&
                  message.mode === 'grounded' && (
                    <div className="sources">
                      {message.citations.length > 0 ? (
                        message.citations.map((citation) => (
                          <span
                            className="source-badge"
                            key={`${citation.fileName}-${citation.source}`}
                            title={citation.source}
                          >
                            Source: {citation.fileName}
                          </span>
                        ))
                      ) : (
                        <span className="no-source">
                          No citation annotation returned for this response.
                        </span>
                      )}
                    </div>
                  )}

How does the source area behave?

Each unique citation becomes a badge using its filename. The source value remains available as the badge title.

Citation annotations are conditional. The fallback reports their absence without claiming that the answer includes evidence the API did not return.

  • Save src/App.jsx.
  • Return to PolicyPal in the browser.
  • Ask Can I return opened earbuds after 45 days? again in Grounded mode.

You should see a source badge naming policy-pal-policies.txt when Gemini returns a file citation. Otherwise, you should see No citation annotation returned for this response.

Compare answers and add cleanup

File Search stores persist until they are deleted. PolicyPal needs a backend deletion route so the browser can remove the remote store using its saved name.

  • Switch back to server.js.
  • Place this deletion route below POST /api/ask and above app.listen():
app.delete('/api/index', async (request, response) => {
  const { storeName } = request.body ?? {};

  if (!isNonEmptyString(storeName)) {
    return response.status(400).json({ error: 'Store name is required.' });
  }

  try {
    await ai.fileSearchStores.delete({
      name: storeName,
      config: { force: true },
    });

    return response.json({ deleted: true });
  } catch (error) {
    return sendError(response, error);
  }
});

What does the deletion route remove?

The route validates the store name before requesting a forced deletion. Its success response lets React clear the matching browser reference only after the remote deletion completes.

  • Save server.js.
  • Return to the backend terminal.
  • Stop the running backend process.
  • Restart the backend using the server command from Step 1.

You should see the PolicyPal API startup message. The backend now supports grounded queries and index deletion.

Does the deletion route stop the server?

Check that the deletion route sits outside POST /api/ask. Confirm that app.listen() remains after both routes.

Still stuck? Help me check the DELETE /api/index route placement.

✔️ Awesome, I've got everything!

Your backend now queries File Search, returns citation evidence, and supports remote-store deletion. Keep the backend running for the remaining checks.

ⓧ I'd like to double check the full code

import 'dotenv/config';
import express from 'express';
import { GoogleGenAI } from '@google/genai';

const apiKey = process.env.GEMINI_API_KEY;

if (!apiKey) {
  throw new Error('Add GEMINI_API_KEY to a local .env file before starting the server.');
}

const app = express();
const ai = new GoogleGenAI({ apiKey });
const port = 3001;
const model = 'gemini-3.8-flash';

app.use(express.json());

const sleep = (milliseconds) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

function isNonEmptyString(value) {
  return typeof value === 'string' && value.trim().length > 0;
}

function sendError(response, error) {
  console.error(error);
  response.status(500).json({
    error: error instanceof Error ? error.message : 'Unexpected server error.',
  });
}

function collectGroundedResponse(interaction) {
  let answer = '';
  const citations = [];

  for (const step of interaction.steps ?? []) {
    if (step.type !== 'model_output') continue;

    for (const contentBlock of step.content ?? []) {
      if (contentBlock.type !== 'text') continue;

      answer += contentBlock.text ?? '';

      for (const annotation of contentBlock.annotations ?? []) {
        if (annotation.type !== 'file_citation') continue;

        citations.push({
          fileName: annotation.file_name || 'Policy document',
          source: annotation.source || '',
        });
      }
    }
  }

  const uniqueCitations = citations.filter(
    (citation, index, allCitations) =>
      index ===
      allCitations.findIndex(
        (item) =>
          item.fileName === citation.fileName && item.source === citation.source,
      ),
  );

  return {
    answer: answer.trim() || interaction.output_text || 'No answer returned.',
    citations: uniqueCitations,
  };
}

app.get('/api/health', (request, response) => {
  response.json({ status: 'ok' });
});

app.post('/api/plain', async (request, response) => {
  const { question } = request.body ?? {};

  if (!isNonEmptyString(question)) {
    return response.status(400).json({ error: 'Question is required.' });
  }

  try {
    const interaction = await ai.interactions.create({
      model,
      input: `Answer briefly as a customer-support assistant. The company is fictional and you have not been given its official policies. User question: ${question.trim()}`,
    });

    return response.json({
      answer: interaction.output_text || 'No answer returned.',
      citations: [],
    });
  } catch (error) {
    return sendError(response, error);
  }
});

app.post('/api/index', async (request, response) => {
  let storeName = '';

  try {
    const store = await ai.fileSearchStores.create({
      config: {
        displayName: 'policy-pal-store',
        embeddingModel: 'models/gemini-embedding-2',
      },
    });

    storeName = store.name;

    let operation = await ai.fileSearchStores.uploadToFileSearchStore({
      file: 'docs/policies.txt',
      fileSearchStoreName: storeName,
      config: {
        displayName: 'policy-pal-policies.txt',
      },
    });

    while (!operation.done) {
      await sleep(2000);
      operation = await ai.operations.get({ operation });
    }

    return response.json({ storeName });
  } catch (error) {
    if (storeName) {
      await ai.fileSearchStores
        .delete({ name: storeName, config: { force: true } })
        .catch(() => {});
    }

    return sendError(response, error);
  }
});

app.post('/api/ask', async (request, response) => {
  const { question, storeName } = request.body ?? {};

  if (!isNonEmptyString(question)) {
    return response.status(400).json({ error: 'Question is required.' });
  }

  if (!isNonEmptyString(storeName)) {
    return response.status(400).json({ error: 'Load the knowledge base first.' });
  }

  try {
    const interaction = await ai.interactions.create({
      model,
      input: `Answer only from the retrieved Acme Gadgets policy. If the policy does not contain the answer, say "I can't find that in the policy." Question: ${question.trim()}`,
      tools: [
        {
          type: 'file_search',
          file_search_store_names: [storeName],
        },
      ],
    });

    return response.json(collectGroundedResponse(interaction));
  } catch (error) {
    return sendError(response, error);
  }
});

app.delete('/api/index', async (request, response) => {
  const { storeName } = request.body ?? {};

  if (!isNonEmptyString(storeName)) {
    return response.status(400).json({ error: 'Store name is required.' });
  }

  try {
    await ai.fileSearchStores.delete({
      name: storeName,
      config: { force: true },
    });

    return response.json({ deleted: true });
  } catch (error) {
    return sendError(response, error);
  }
});

app.listen(port, (error) => {
  if (error) throw error;
  console.log(`PolicyPal API running at http://localhost:${port}`);
});

How to use this reference

Compare this complete file with your saved server.js. Check the helper and route order before returning to the main instructions.

The backend cleanup path is ready. React now needs to call it before clearing the store name from browser storage.

  • Switch back to src/App.jsx.
  • Place this function below loadKnowledgeBase():
  async function deleteKnowledgeBase() {
    setBusy(true);
    setStatus('Deleting the knowledge base...');

    try {
      await requestJson('/api/index', {
        method: 'DELETE',
        body: JSON.stringify({ storeName }),
      });
      localStorage.removeItem('policyPalStore');
      setStoreName('');
      setMode('plain');
      setStatus('Knowledge base not loaded');
    } catch (error) {
      setStatus(error.message);
    } finally {
      setBusy(false);
    }
  }

Why clear local storage last?

The function removes policyPalStore only after the backend confirms deletion. A failed request therefore preserves the remote-store reference for another cleanup attempt.

A successful deletion also returns PolicyPal to ungrounded mode. The status confirms that the knowledge base is no longer available.

  • Find the store-actions section in the toolbar.
  • Replace its current knowledge-base action with this conditional control:
          <div className="store-actions">
            {!storeName ? (
              <button
                className="secondary"
                disabled={busy}
                onClick={loadKnowledgeBase}
                type="button"
              >
                Load knowledge base
              </button>
            ) : (
              <button
                className="danger"
                disabled={busy}
                onClick={deleteKnowledgeBase}
                type="button"
              >
                Delete index
              </button>
            )}
          </div>

How does the action change?

An empty store name presents the load action. A saved store name presents Delete index so the learner cannot create an accidental duplicate.

  • Save src/App.jsx.
  • Return to PolicyPal in the browser.

You should see Delete index because the ready store name is still saved. Keep the store for the comparison and Secret Mission checks.

Do you still see the load action?

Confirm that the status says Knowledge base ready. A cleared browser reference makes React show the load action.

Still stuck? Help me check the conditional knowledge-base control.

✔️ Awesome, I've got everything!

Your React app now routes both answer modes, renders source evidence, and exposes safe index cleanup. Keep the current store loaded for the final comparison.

ⓧ I'd like to double check the full code

import { useState } from 'react';

const sampleQuestions = [
  'Can I return opened earbuds after 45 days?',
  'When should my refund reach my card?',
  'How long is the warranty?',
];

async function requestJson(url, options = {}) {
  const response = await fetch(url, {
    headers: {
      'Content-Type': 'application/json',
    },
    ...options,
  });

  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.error || 'The request failed.');
  }

  return data;
}

export default function App() {
  const [mode, setMode] = useState('plain');
  const [question, setQuestion] = useState('');
  const [messages, setMessages] = useState([]);
  const [busy, setBusy] = useState(false);
  const [storeName, setStoreName] = useState(
    () => localStorage.getItem('policyPalStore') || '',
  );
  const [status, setStatus] = useState(
    storeName ? 'Knowledge base ready' : 'Knowledge base not loaded',
  );

  async function loadKnowledgeBase() {
    setBusy(true);
    setStatus('Indexing the policy document...');

    try {
      const data = await requestJson('/api/index', { method: 'POST' });
      localStorage.setItem('policyPalStore', data.storeName);
      setStoreName(data.storeName);
      setMode('grounded');
      setStatus('Knowledge base ready');
    } catch (error) {
      setStatus(error.message);
    } finally {
      setBusy(false);
    }
  }

  async function deleteKnowledgeBase() {
    setBusy(true);
    setStatus('Deleting the knowledge base...');

    try {
      await requestJson('/api/index', {
        method: 'DELETE',
        body: JSON.stringify({ storeName }),
      });
      localStorage.removeItem('policyPalStore');
      setStoreName('');
      setMode('plain');
      setStatus('Knowledge base not loaded');
    } catch (error) {
      setStatus(error.message);
    } finally {
      setBusy(false);
    }
  }

  async function handleSubmit(event) {
    event.preventDefault();
    const trimmedQuestion = question.trim();

    if (!trimmedQuestion || busy) return;

    if (mode === 'grounded' && !storeName) {
      setStatus('Load the knowledge base before using Grounded mode.');
      return;
    }

    setQuestion('');
    setBusy(true);
    setMessages((current) => [
      ...current,
      { role: 'user', text: trimmedQuestion },
    ]);

    try {
      const url = mode === 'grounded' ? '/api/ask' : '/api/plain';
      const payload =
        mode === 'grounded'
          ? { question: trimmedQuestion, storeName }
          : { question: trimmedQuestion };

      const data = await requestJson(url, {
        method: 'POST',
        body: JSON.stringify(payload),
      });

      setMessages((current) => [
        ...current,
        {
          role: 'assistant',
          text: data.answer,
          mode,
          citations: data.citations || [],
        },
      ]);
    } catch (error) {
      setMessages((current) => [
        ...current,
        {
          role: 'assistant',
          text: error.message,
          mode,
          citations: [],
          error: true,
        },
      ]);
    } finally {
      setBusy(false);
    }
  }

  return (
    <main className="app-shell">
      <section className="app-card">
        <header className="hero">
          <div>
            <p className="eyebrow">React + Node.js + Gemini File Search</p>
            <h1>PolicyPal</h1>
            <p className="subtitle">
              Compare a generic AI answer with one grounded in Acme Gadgets'
              policy.
            </p>
          </div>
          <span className={`status ${storeName ? 'ready' : ''}`}>{status}</span>
        </header>

        <section className="toolbar" aria-label="Answer controls">
          <div className="mode-switcher">
            <button
              className={mode === 'plain' ? 'active' : ''}
              disabled={busy}
              onClick={() => setMode('plain')}
              type="button"
            >
              Ungrounded
            </button>
            <button
              className={mode === 'grounded' ? 'active' : ''}
              disabled={busy || !storeName}
              onClick={() => setMode('grounded')}
              type="button"
            >
              Grounded
            </button>
          </div>

          <div className="store-actions">
            {!storeName ? (
              <button
                className="secondary"
                disabled={busy}
                onClick={loadKnowledgeBase}
                type="button"
              >
                Load knowledge base
              </button>
            ) : (
              <button
                className="danger"
                disabled={busy}
                onClick={deleteKnowledgeBase}
                type="button"
              >
                Delete index
              </button>
            )}
          </div>
        </section>

        <section className="samples" aria-label="Sample questions">
          {sampleQuestions.map((sample) => (
            <button
              disabled={busy}
              key={sample}
              onClick={() => setQuestion(sample)}
              type="button"
            >
              {sample}
            </button>
          ))}
        </section>

        <section className="conversation" aria-live="polite">
          {messages.length === 0 ? (
            <div className="empty-state">
              <h2>Ask an Acme policy question</h2>
              <p>
                Start in Ungrounded mode, then load the policy and compare the
                same question in Grounded mode.
              </p>
            </div>
          ) : (
            messages.map((message, index) => (
              <article
                className={`message ${message.role} ${
                  message.error ? 'message-error' : ''
                }`}
                key={`${message.role}-${index}`}
              >
                <div className="message-meta">
                  <strong>
                    {message.role === 'user' ? 'You' : 'PolicyPal'}
                  </strong>
                  {message.mode && (
                    <span>
                      {message.mode === 'grounded' ? 'Grounded' : 'Ungrounded'}
                    </span>
                  )}
                </div>
                <p>{message.text}</p>

                {message.role === 'assistant' &&
                  message.mode === 'grounded' && (
                    <div className="sources">
                      {message.citations.length > 0 ? (
                        message.citations.map((citation) => (
                          <span
                            className="source-badge"
                            key={`${citation.fileName}-${citation.source}`}
                            title={citation.source}
                          >
                            Source: {citation.fileName}
                          </span>
                        ))
                      ) : (
                        <span className="no-source">
                          No citation annotation returned for this response.
                        </span>
                      )}
                    </div>
                  )}
              </article>
            ))
          )}
        </section>

        <form className="composer" onSubmit={handleSubmit}>
          <label htmlFor="question">Your question</label>
          <div className="composer-row">
            <input
              disabled={busy}
              id="question"
              onChange={(event) => setQuestion(event.target.value)}
              placeholder="Ask about returns, refunds, shipping, or warranty"
              value={question}
            />
            <button disabled={busy || !question.trim()} type="submit">
              {busy ? 'Working...' : 'Ask'}
            </button>
          </div>
        </form>
      </section>
    </main>
  );
}

How to use this reference

Compare this complete file with your saved src/App.jsx. Focus on the two knowledge-base functions, the mode-aware submit handler, the toolbar action, and the source area.

Both answer paths are now connected. Comparing the same question reveals what policy retrieval adds to the final app.

  • Select Ungrounded in the mode switcher.
  • Enter Can I return opened earbuds after 45 days? in the question field.

Before you submit the question, do you expect a generic model to know Acme Gadgets' fictional return policy?

  • Click Ask.

You should see an AI response with an Ungrounded label. Its message has no policy source area.

  • Select Grounded in the mode switcher.
  • Enter Can I return opened earbuds after 45 days? again.

Before you submit the grounded version, which details from the indexed policy do you expect retrieval to surface?

  • Click Ask.

You should see a Grounded answer applying the 30-calendar-day limit. It should also apply the rule that opened earbuds cannot be returned unless they are defective.

The source area should show policy-pal-policies.txt when Gemini supplies a citation annotation. The explicit fallback appears when no annotation is returned.

That comparison proves the central result: the grounded path can connect its answer to Acme's indexed rules. The ungrounded path cannot demonstrate that connection.

Before the production check, what do you expect Vite to report if every import and JSX expression is valid?

  • Return to the terminal for the policy-pal folder.
  • Check the production build by running:
npm run build

What does this command check?

The build script asks Vite to compile the React application for production. This surfaces invalid imports and JSX syntax before deployment.

You should see Vite complete the production build without an error. That is the hard part complete: PolicyPal can now compare an ungrounded answer with policy-grounded evidence.

Does the production build fail?

Compare the reported file and line with the full src/App.jsx reference above. Citation rendering often fails when one closing parenthesis or brace is missing.

Still stuck? Help me diagnose the PolicyPal production build failure.

Secret mission

Stress-Test the Knowledge Boundary

Test an exact rule. Try a paraphrase. Probe an unsupported claim to see whether PolicyPal respects the policy boundary.

Clean Up Your Resources

Clean Up Your Resources

Small tests on the Gemini API free tier are expected to cost $0. Decide whether to keep PolicyPal running, pause its local services, or delete its persistent Gemini File Search store and local files.

Cost warning

If this project is attached to paid billing or used beyond free limits, model tokens and indexing can incur charges.

Pausing prevents new requests. Deleting also removes the persistent indexed store.

Resources you used:

  • The local policy-pal folder containing the React and Vite frontend, the Express backend, docs/policies.txt, the protected .env file, and installed dependencies.
  • The two local PolicyPal services currently running in Visual Studio Code terminals.
  • The persistent Gemini File Search store with display name policy-pal-store containing policy-pal-policies.txt.
  • The browser reference stored under policyPalStore that points PolicyPal to the remote store.

The temporary raw file is deleted after 48 hours. The indexed store remains until you delete it.

Keep everything running

No action needed. Choose this if you want to continue testing PolicyPal with the indexed policy.

  • Leave the frontend service running in its Visual Studio Code terminal.
  • Leave the backend service running in its Visual Studio Code terminal.
  • Keep the Gemini File Search store available for more grounded tests.
  • Keep the policy-pal folder available for future development.

Pause - I'll come back to this later

Shut down the running processes to free up memory while keeping the local files and remote store for later.

  • Switch back to the Visual Studio Code terminal running the frontend service.
  • Press Ctrl+C to stop the frontend service.
  • Switch back to the Visual Studio Code terminal running the backend service.
  • Press Ctrl+C to stop the backend service.

Your policy-pal folder and remote File Search store remain available when you return.

Delete - I don't want to use this again

Removing everything is permanent. This sequence deletes the remote index before you remove the backend that performs the deletion.

  • Return to the PolicyPal browser tab from earlier.
  • Click Delete index.

You should see Knowledge base not loaded. This confirms that PolicyPal deleted the remote store and cleared the policyPalStore browser reference.

That is the remote cleanup complete. The indexed policy can no longer receive grounded queries from this app.

  • Switch back to the Visual Studio Code terminal running the frontend service.
  • Press Ctrl+C to stop the frontend service.
  • Switch back to the Visual Studio Code terminal running the backend service.
  • Press Ctrl+C to stop the backend service.
  • Open the Windows file browser from the taskbar.
  • Navigate to the folder that contains policy-pal.
  • Delete the policy-pal folder.

You should no longer see policy-pal in its previous location. This removes the source code, installed dependencies, production build, policy document, and local .env file.

Nice Work!

Nice Work!

You did it! Your full-stack PolicyPal assistant now compares generic AI answers with responses grounded in Acme Gadgets' policy.

You've learned how to:

  • Build a responsive React chat interface for policy questions. Manage controlled inputs. Preserve message history. Show loading states. Keep request errors visible. Switch between ungrounded mode and grounded mode.
  • Build an Express API for frontend requests. Validate JSON input before each model call. Protect the Gemini API key behind the backend. Connect browser requests through Vite's development proxy.
  • Create a Gemini File Search store from a fictional policy document. Query that store through a RAG flow. Compare grounded responses against ungrounded model answers. Display citation filenames when annotations are returned. Delete the persistent store from PolicyPal.
  • Secret Mission: Stress-test direct retrieval with an exact policy question. Test semantic retrieval with a paraphrased refund question. Confirm that an unsupported discount question produces a policy-not-found response. Check returned citation badges against policy-pal-policies.txt.

Ready to quiz yourself?