Local AI Sales Qualification Assistant
Build a local AI sales assistant that qualifies leads and routes them to Sheets.
Introduction
30 Second Summary
When every prospect gets the same questions, sales teams can miss the detail that reveals what the customer actually needs. A confident recommendation can still be hard to trust when nobody can show where it came from.
In this project, you will build a browser-based MTS sales qualification assistant that asks a service-specific follow-up for each prospect's challenge. The result combines a verified MTS match with a local LLM explanation plus an automated Google Sheets handoff for qualified prospects.
What You'll Build
A completed branded chat ends with a source-backed MTS recommendation in the browser plus a qualified profile in the shared sales sheet.
By the end of this project, you'll have:
- An adaptive browser conversation that captures contact details before asking a service-specific question about the prospect's challenge.
- An evidence-backed decision that displays the identified requirement beside the selected MTS solution. Its grounded explanation includes official MTS links. The retriever interface can later accept an embedding implementation.
- A containerized handoff that runs the complete web app in Docker while Ollama stays on Windows. Qualified prospects reach Google Sheets while low-intent profiles stay outside the sales queue.
- Secret Mission: Run an adversarial three-case test matrix for adaptive questioning, grounded service selection, unsupported claims, and blocked routing for a low-intent prospect.
Do I need anything before starting?
Your computer needs Windows 10 22H2 or newer with at least 8 GB RAM plus enabled hardware virtualization.
You also need internet access plus a Google account that can create a spreadsheet with a Google Apps Script deployment.
Before We Start
You are building a Docker-deployed MTS sales qualification engine with one trusted path from requirement discovery to verified service matching to grounded local LLM explanation to a qualified-only Google Sheets handoff. Verified records and deterministic rules prevent unsupported or invented service selections.
Prepare the Windows AI Development Environment
Your assistant depends on three local foundations. Python runs the application. Ollama serves the local model. Docker Desktop runs the finished application in a Linux container.
This step proves each foundation before coding begins. You will also prepare the project files in VS Code so the next step can focus on the browser experience.
In this step, get ready to:
- Prepare an isolated Python project environment.
- Download the gemma3:1b local model through Ollama.
- Prove that Docker Desktop can reach its Linux container engine.
Verify Python and prepare the project
A virtual environment keeps this project's packages separate from other Python projects on your computer. This one installs FastAPI 0.162.0 with its standard extras.
It also installs Pydantic 2.14.3. Both packages require a compatible Python runtime.
- Press the Windows key to open Windows search.
- Type PowerShell into the search field.
- Press Enter to open PowerShell.
Before you run the check, do you expect the output to identify Python 3.10 or a newer version?
- Check the Python version used by pip by running this command:
py -m pip --version
What does this check prove?
The output identifies the Python installation behind pip. The project needs Python 3.10 or newer.
✔️ My version is supported
Your output identifies Python 3.10 or newer. Your runtime is compatible with the pinned FastAPI release.
- Keep this PowerShell window available for the project commands.
ⓧ I see an older version
The installed runtime is below Python 3.10. Install a current Python release alongside it.
- Visit the official Python releases for Windows.
- Download a current Windows installer.
- Run the downloaded installer.
- Select Install Now.
- Keep the Python Launcher option selected.
- Close PowerShell after installation finishes.
- Open a fresh PowerShell window through Windows search.
- Check the selected runtime again by running:
py -m pip --version
What should change?
The refreshed output should identify Python 3.10 or newer. The Python Launcher selects the newer compatible installation.
Still seeing the older version?
The older launcher may still be active in your existing terminal session. Close every PowerShell window before checking again.
Ask for help: Help me make the Windows Python Launcher select Python 3.10 or newer.
ⓧ Command not found
Windows cannot find the Python Launcher. Install a current Python release with the launcher included.
- Visit the official Python releases for Windows.
- Download a current Windows installer.
- Run the downloaded installer.
- Select Install Now.
- Keep the Python Launcher option selected.
- Open a fresh PowerShell window after installation.
- Check the installation by running:
py -m pip --version
What should you see?
The command should now print a pip version plus its Python runtime. Confirm that the runtime is Python 3.10 or newer.
Launcher still unavailable?
The installer may not have added the launcher to your command path. Run the installer again with the Python Launcher option selected.
Ask for help: Help me restore the py command after installing Python on Windows.
- Press the Windows key to open Windows search.
- Type VS Code into the search field.
- Press Enter to open VS Code.
Can't find VS Code?
Install the recommended Windows User setup from the official VS Code Windows guide. The User setup installs without administrator permissions for most learners.
Ask for help: Help me install the VS Code Windows User setup.
- Click File in the VS Code top menu.
- Click Open Folder.
- Navigate to your Desktop in the folder picker.
- Click New Folder.
- Enter mts-sales-assistant as the folder name.
- Click Select Folder.
Where is the project now?
VS Code now treats the Desktop folder named mts-sales-assistant as the project root. Every file created in this project belongs inside that folder.
- Click Terminal in the VS Code top menu.
- Click New Terminal.
- Create the isolated environment inside mts-sales-assistant by running:
py -m venv .venv
What did this create?
Python created the .venv directory inside your project root. That directory holds this project's isolated interpreter plus its installed packages.
- Confirm that .venv appears in the VS Code Explorer sidebar.
- Activate the virtual environment by running:
.venv\Scripts\activate
What does activation change?
Activation makes project commands use the interpreter inside .venv. Your terminal prompt should now show the virtual environment name.
Activation blocked?
Confirm that the terminal is positioned inside mts-sales-assistant. Confirm that the .venv directory exists in the Explorer sidebar.
Ask for help: Help me activate this Windows virtual environment.
- Click the New File icon in the VS Code Explorer sidebar.
- Enter requirements.txt as the filename.
- Add the pinned project dependencies with this content:
fastapi[standard]==0.162.0
pydantic==2.14.3
What does this file control?
- The first line installs FastAPI 0.162.0 with its standard command-line tools.
- The second line installs Pydantic 2.14.3 for validated API request models.
- Save requirements.txt.
- Confirm that requirements.txt appears beside .venv in the Explorer sidebar.
- Click the New File icon in the Explorer sidebar.
- Enter pyproject.toml as the filename.
- Define the future FastAPI entrypoint with this content:
[tool.fastapi]
entrypoint = "app.main:app"
What does this configuration do?
The FastAPI command-line tool reads this configuration to locate the application object. A later step creates that object in app/main.py.
- Save pyproject.toml.
- Confirm that pyproject.toml appears in the Explorer sidebar.
- Install the pinned dependencies into the active environment by running:
py -m pip install -r requirements.txt
What does this installation use?
The command reads both exact package versions from requirements.txt. The active virtual environment keeps those packages scoped to this project.
The terminal should complete without a package installation error. The output should mention FastAPI 0.162.0 plus Pydantic 2.14.3.
Dependency installation failed?
Confirm that the terminal prompt shows the virtual environment name. Check that both lines in requirements.txt match the pinned versions exactly.
Ask for help: Help me debug this pinned FastAPI dependency installation.
✔️ Awesome, I've got everything!
Your virtual environment is active. Both project configuration files are saved in mts-sales-assistant.
ⓧ I'd like to double check the full code
Compare each project file with the complete version below.
fastapi[standard]==0.162.0
pydantic==2.14.3
What should match?
Your requirements.txt file should contain exactly these two dependency pins.
[tool.fastapi]
entrypoint = "app.main:app"
What should match here?
Your pyproject.toml file should contain this FastAPI entrypoint exactly.
Install Ollama and test the local model
The assistant generates explanations with a model that stays on your Windows host. Ollama provides the local model server that later receives chat requests from FastAPI.
- Open http://localhost:11434/api/version in your browser to check the local Ollama server.
✔️ Ollama is current
The page returns a JSON version value of 0.6 or newer. Ollama is running in the Windows background.
ⓧ I see an older version
The installed version is below Ollama 0.6. The selected model requires a newer release.
- Open the Ollama item in the Windows taskbar area.
- Select Restart to update if that option is available.
- Download the latest OllamaSetup.exe from the official Ollama Windows guide if no update option appears.
- Run OllamaSetup.exe to update the application.
- Revisit http://localhost:11434/api/version after the update.
Version still unchanged?
Quit the existing Ollama background process before running the latest installer again. Reopen Ollama from Windows search after installation.
Ask for help: Help me update Ollama on Windows for gemma3:1b.
ⓧ The page is unavailable
Ollama is missing or its Windows background process is stopped. Install the native Windows application before downloading the model.
- Visit the official Ollama Windows guide.
- Download OllamaSetup.exe.
- Run the installer under your Windows account.
- Open Ollama through Windows search after installation.
- Revisit http://localhost:11434/api/version.
Local server still unavailable?
Confirm that Windows is version 10 22H2 or newer. Corporate endpoint controls can also block a local background service.
Ask for help: Help me start the Ollama Windows background service.
The first model run downloads an 806 MB file. A quiet wait during that download is normal.
- Download the model and start its interactive session by running:
ollama run gemma3:1b
What does this command do?
Ollama downloads gemma3:1b when it is absent from your computer. It then opens an interactive prompt backed by the local model.
- Enter Reply with the word ready. at the interactive prompt.
- Press Enter to generate a local response.
You should see a generated reply in the terminal. That response proves the model can perform local inference.
- Press Ctrl+C to close the interactive model session.
- Confirm that the downloaded model is available by running:
ollama ls
What does the model list prove?
The local model list shows what Ollama can serve without downloading again. You should see gemma3:1b in that list.
Model missing from the list?
The download may have stopped before completion. Run the model command again on a stable connection.
Ask for help: Help me finish downloading gemma3:1b with Ollama.
Install Docker Desktop and verify account access
The finished application runs inside a Linux container while Ollama remains on Windows. Docker Desktop provides the container engine plus the host networking used by that design.
The licensing screen deserves a careful pause. Docker Personal costs $0 for personal learning.
Check the commercial boundary
Commercial Docker Desktop use requires a paid subscription when an enterprise has 250 or more employees. The same requirement applies when annual revenue exceeds $10 million USD.
Confirm MTS licensing before using this setup for business work. This project remains within personal learning use.
- Check whether the Docker engine is already reachable by running:
docker ps
What does this Docker check prove?
The command asks the Docker engine for its current container table. A returned table proves that the command-line client can reach the running engine.
✔️ I see a container table
Docker returned a table without a daemon error. Docker Desktop is ready for this project.
ⓧ I see a daemon error
The Docker command exists. Its Linux engine is currently stopped.
- Press the Windows key to open Windows search.
- Type Docker Desktop into the search field.
- Press Enter to start Docker Desktop.
The first start can take a few minutes while the Linux engine becomes ready. That wait does not mean the application has frozen.
- Retry the engine check after Docker Desktop reports that it is running:
docker ps
What should change?
The daemon error should be replaced by a container table. The table can be empty because no project container exists yet.
Engine still unavailable?
Confirm that hardware virtualization is enabled. Confirm that Docker Desktop is using its WSL 2 backend.
Ask for help: Help me resolve this Docker Desktop daemon error on Windows.
ⓧ Command not found
Docker Desktop is not installed or its command is unavailable. This project requires Windows 10 22H2 or newer plus at least 8 GB of RAM.
- Confirm that hardware virtualization is enabled on this computer.
- Follow the official Docker Desktop Windows installation guide.
- Choose the recommended per-user installation mode.
- Keep the WSL 2 backend selected.
- Allow the Windows approval prompt if your device policy permits the installation.
- Accept the subscription terms only after confirming that your use fits the personal-learning allowance.
- Start Docker Desktop through Windows search.
The first start can take a few minutes while Docker prepares its Linux engine. Wait for Docker Desktop to report that the engine is running.
- Verify the installation by running:
docker ps
What should you see?
Docker should return a container table without a daemon error. An empty table is a successful result at this stage.
Installation blocked?
Managed Windows devices may block virtualization features or software installation. Ask your device administrator to confirm the Docker Desktop prerequisites.
Ask for help: Help me check the Docker Desktop prerequisites on Windows.
A later handoff sends qualified prospects to Google Sheets. This access check prevents an account problem from surfacing during that handoff.
- Open Google Sheets in your browser.
- Sign in with your existing Google account if prompted.
- Confirm that your account offers the option to create a blank spreadsheet.
If organizational policy blocks access, resolve that restriction before the Sheets handoff step. No spreadsheet needs to be created yet.
Before the final check, which two local resources should appear ready?
- Prove that the model and container engine are available by running these commands:
ollama ls
docker ps
What proves the environment is ready?
- The Ollama output should list gemma3:1b.
- The Docker output should show a container table without a daemon error.
Your Windows AI development environment is ready. Next, you will turn these foundations into a FastAPI-served browser conversation.
Build the Conversation Shell
Your verified local environment can now host the first visible product slice. The next problem is giving each prospect a browser flow that exchanges structured messages with the backend.
You'll use FastAPI to serve a JavaScript chat interface. A small REST API will carry each answer through the nine-stage conversation.
In this step, get ready to:
- Create the backend request models and temporary conversation stages.
- Build the responsive browser interface for the qualification flow.
- Serve the interface and prove that an answer advances the conversation.
Create the backend shell
The backend needs a stable contract for messages sent between the browser and server. Pydantic models define that contract before qualification logic or model-generated recommendations enter the flow.
- In the VS Code Explorer sidebar from earlier, create an app folder at the project root.
- Inside app, create a frontend folder.
- Inside app, create __init__.py with this package description:
"""MTS AI sales qualification assistant."""
Why create this file?
The __init__.py file identifies app as a Python package. This lets the server import project modules through paths that begin with app.
- Save app/__init__.py.
- Confirm that __init__.py appears under app in the Explorer sidebar.
- Create app/sales_qualification.py with the temporary stage definitions below:
QUALIFICATION_FIELDS = (
("contact_name", "What is your full name?"),
("company", "What company or organization do you represent?"),
("work_email", "What work email should the sales team use?"),
("phone", "What phone number should the sales team use?"),
("challenge", "What energy, carbon, or sustainability challenge would you like to solve?"),
("requirement_detail", "Please add one detail about the outcome or scope you need."),
("role", "Choose your role: decision maker, evaluator, or researcher."),
("timeline", "Choose a timeline: 0-3 months, 3-6 months, or 6+ months."),
("budget", "Choose the budget readiness: exploring, planning, or approved."),
)
How do the stages work?
- Each tuple pairs a stored answer key with the next question shown to the prospect.
- The temporary requirement_detail question is generic. A later step replaces its behavior with a service-specific question.
- The tuple order controls the browser progress calculation. Nine completed answers represent the complete profile.
- Save app/sales_qualification.py.
- Confirm that the file contains nine field and question pairs.
- Create app/main.py with these imports and data models:
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from app.sales_qualification import QUALIFICATION_FIELDS
FRONTEND_DIR = Path(__file__).resolve().parent / "frontend"
class ChatRequest(BaseModel):
stage: int
message: str
answers: dict[str, str]
class Source(BaseModel):
title: str
url: str
class ChatResponse(BaseModel):
stage: int
assistant_message: str
complete: bool
answers: dict[str, str]
requirement_summary: str
appropriate_solution: str
qualification_status: str
transfer_status: str
sources: list[Source]
What do these models control?
- ChatRequest validates the current stage, the new message, and the answers already collected.
- Source defines the title and URL shape that verified evidence will use later.
- ChatResponse reserves fields for the requirement, solution, qualification result, transfer result, and sources.
- Add the FastAPI application and health route directly below the models:
app = FastAPI(title="MTS AI Sales Qualification Assistant", version="1.0.0")
retriever = None
qualification_engine = None
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok", "model": "gemma3:1b"}
What does the health route prove?
The /api/health route proves that the FastAPI process can answer a request. The response also identifies the local model that later powers the recommendation.
The two empty engine references reserve the decision boundary. Qualification and retrieval logic arrive in a later step.
- Add the temporary chat route below the health route:
@app.post("/api/chat", response_model=ChatResponse)
def chat(request: ChatRequest) -> ChatResponse:
stage = max(0, min(request.stage, len(QUALIFICATION_FIELDS) - 1))
answers = dict(request.answers)
answer_key, _ = QUALIFICATION_FIELDS[stage]
answers[answer_key] = request.message.strip()
next_stage = min(stage + 1, len(QUALIFICATION_FIELDS))
complete = next_stage == len(QUALIFICATION_FIELDS)
assistant_message = (
"Collecting qualification details"
if complete
else QUALIFICATION_FIELDS[next_stage][1]
)
return ChatResponse(
stage=next_stage,
assistant_message=assistant_message,
complete=complete,
answers=answers,
requirement_summary="",
appropriate_solution="",
qualification_status="Collecting qualification details",
transfer_status="Not evaluated",
sources=[],
)
How does the temporary route respond?
- The route clamps the incoming stage to a valid position in the nine-stage flow.
- The current answer is stored under the matching key without changing earlier answers.
- The response advances to the next question while leaving all qualification result fields empty.
- Save app/main.py.
- Return to the activated PowerShell terminal from the previous step.
- Start the development server by running this command:
fastapi dev
What does development mode do?
The command reads the entry point from pyproject.toml and serves the application at http://127.0.0.1:8000. It keeps this terminal occupied while the server runs.
FastAPI watches the project files for saved changes. This keeps the edit and test loop short.
- Open http://127.0.0.1:8000/docs in your browser.
- Confirm that the documentation lists GET /api/health and POST /api/chat.
- Open http://127.0.0.1:8000/api/health in another browser tab.
You'll see a JSON response with status set to ok and model set to gemma3:1b. Your first API response is live.
Server not starting?
- Check that the terminal prompt still shows the activated .venv environment.
- Confirm that app/main.py sits inside the project root that contains pyproject.toml.
Ask for help: Help me diagnose why fastapi dev cannot load app.main:app from my project root.
Build the browser interface
The browser needs regions for the conversation and the prospect result. HTML supplies those regions while CSS turns them into a responsive two-column workspace.
- Inside app/frontend, create index.html with the page shell and hero section below:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MTS AI Sales Qualification Assistant</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<main class="app-shell">
<section class="hero">
<p class="eyebrow">MTS Energy and Carbon Audit Services</p>
<h1>AI Sales Qualification Assistant</h1>
<p>Describe your challenge and let the qualification engine identify the right MTS solution.</p>
</section>
</main>
<script src="/static/app.js" defer></script>
</body>
</html>
What does the page shell provide?
The document links the stylesheet and defers the browser script until the page structure is ready. The hero introduces the assistant before the conversation starts.
- Inside the main element, add this chat card below the hero section:
<section class="workspace">
<article class="chat-card">
<div class="progress-row">
<span id="progress-label">Qualification progress: 0%</span>
<button id="reset-button" class="text-button" type="button">Reset</button>
</div>
<div class="progress-track" aria-hidden="true">
<div id="progress-bar" class="progress-bar"></div>
</div>
<div id="messages" class="messages" aria-live="polite"></div>
<form id="chat-form" class="chat-form">
<label for="message-input">Your answer</label>
<div class="input-row">
<textarea id="message-input" rows="3" required placeholder="Type your response"></textarea>
<button id="send-button" type="submit">Send</button>
</div>
</form>
</article>
</section>
How is the conversation organized?
- The progress elements show how much of the nine-stage profile is complete.
- The live message region receives both assistant questions and prospect answers.
- The form gives the browser script stable element IDs for submission and reset behavior.
- Inside the workspace section, add this result card below the chat article:
<aside class="lead-card">
<p class="eyebrow">Qualification result</p>
<h2>Prospect summary</h2>
<p id="status" class="status">Collecting qualification details</p>
<p id="transfer-status" class="transfer-status" hidden></p>
<dl id="lead-details" class="lead-details"></dl>
<section id="decision-section" hidden>
<h3>Identified requirement</h3>
<p id="requirement-summary"></p>
<h3>Appropriate MTS solution</h3>
<p id="appropriate-solution"></p>
</section>
<section id="recommendation-section" hidden>
<h3>Grounded explanation</h3>
<p id="recommendation"></p>
</section>
<section id="sources-section" hidden>
<h3>Verified MTS sources</h3>
<ul id="sources-list"></ul>
</section>
<button id="download-button" class="secondary-button" type="button" hidden>
Download prospect JSON
</button>
</aside>
Why reserve these result regions now?
The result card separates deterministic decisions from the generated explanation. Its hidden sections become visible only when a complete profile supplies content for them.
The transfer status and download control also have stable locations. Later features can populate them without redesigning the page.
- Save app/frontend/index.html.
- Confirm that VS Code shows balanced opening and closing tags without an HTML problem marker.
- Create app/frontend/styles.css with the page theme and card layout below:
:root {
color-scheme: light;
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f3f7f5;
color: #17332a;
--green-900: #173f32;
--green-700: #246b4f;
--green-500: #3b936c;
--green-100: #dff0e8;
--line: #d8e4de;
--white: #ffffff;
--shadow: 0 18px 50px rgba(23, 63, 50, 0.12);
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; }
button, textarea { font: inherit; }
button { cursor: pointer; }
.app-shell { width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: 40px 0 64px; }
.hero { max-width: 760px; margin-bottom: 28px; }
.eyebrow { margin: 0 0 8px; color: var(--green-700); font-size: 0.78rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
h1, h2, h3, p { margin-top: 0; }
h1 { margin-bottom: 12px; font-size: clamp(2rem, 5vw, 3.7rem); line-height: 1.02; }
.hero > p:last-child { color: #4e665d; font-size: 1.08rem; line-height: 1.7; }
.workspace { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(300px, 0.9fr); gap: 24px; }
.chat-card, .lead-card { border: 1px solid var(--line); border-radius: 22px; background: var(--white); box-shadow: var(--shadow); }
.chat-card { padding: 24px; }
.lead-card { align-self: start; padding: 24px; position: sticky; top: 20px; }
What does this styling establish?
- The color variables keep the MTS green palette consistent across controls and status elements.
- The workspace grid gives the conversation more width than the result card.
- The shared card styling creates a clear visual boundary around each part of the workflow.
- Add the conversation and form styling below the existing CSS:
.progress-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: #52685f; font-size: 0.9rem; }
.text-button { border: 0; background: transparent; color: var(--green-700); font-weight: 700; }
.progress-track { height: 8px; margin: 10px 0 24px; overflow: hidden; border-radius: 999px; background: var(--green-100); }
.progress-bar { width: 0; height: 100%; background: var(--green-500); transition: width 180ms ease; }
.messages { display: flex; min-height: 360px; max-height: 520px; flex-direction: column; gap: 14px; overflow-y: auto; padding: 4px 4px 20px; }
.message { max-width: 82%; padding: 13px 15px; border-radius: 16px; line-height: 1.55; white-space: pre-wrap; }
.message.assistant { align-self: flex-start; border-bottom-left-radius: 4px; background: var(--green-100); }
.message.user { align-self: flex-end; border-bottom-right-radius: 4px; background: var(--green-900); color: var(--white); }
.chat-form { padding-top: 20px; border-top: 1px solid var(--line); }
.chat-form label { display: block; margin-bottom: 8px; font-size: 0.88rem; font-weight: 700; }
.input-row { display: grid; grid-template-columns: 1fr auto; gap: 10px; }
textarea { width: 100%; resize: vertical; border: 1px solid #b9cdc4; border-radius: 12px; padding: 12px; color: inherit; }
textarea:focus { outline: 3px solid rgba(59, 147, 108, 0.18); border-color: var(--green-500); }
#send-button, .secondary-button { border: 0; border-radius: 12px; padding: 12px 18px; background: var(--green-700); color: var(--white); font-weight: 800; }
#send-button:disabled { cursor: wait; opacity: 0.6; }
How does the chat stay readable?
Assistant messages align left while prospect messages align right. The progress bar changes width through JavaScript after each accepted answer.
The disabled send button provides feedback while a request is in progress. This prevents duplicate submissions.
- Add the result-card and mobile styling at the bottom of styles.css.
.status { display: inline-block; border-radius: 999px; padding: 7px 11px; background: var(--green-100); color: var(--green-900); font-size: 0.85rem; font-weight: 800; }
.transfer-status { margin: 12px 0 0; color: #4e665d; line-height: 1.5; }
.lead-details { display: grid; gap: 14px; margin: 22px 0; }
.lead-details div { padding-bottom: 12px; border-bottom: 1px solid var(--line); }
.lead-details dt { color: #60746c; font-size: 0.78rem; font-weight: 800; text-transform: uppercase; }
.lead-details dd { margin: 5px 0 0; line-height: 1.45; }
#decision-section, #recommendation, #requirement-summary { line-height: 1.65; }
#sources-list { padding-left: 20px; }
#sources-list a { color: var(--green-700); font-weight: 700; }
.secondary-button { width: 100%; margin-top: 12px; background: var(--green-900); }
@media (max-width: 860px) { .workspace { grid-template-columns: 1fr; } .lead-card { position: static; } }
@media (max-width: 560px) { .app-shell { width: min(100% - 20px, 1180px); padding-top: 24px; } .chat-card, .lead-card { border-radius: 16px; padding: 18px; } .input-row { grid-template-columns: 1fr; } .message { max-width: 92%; } }
What changes on smaller screens?
The workspace becomes one column when the viewport narrows. The result card also stops using sticky positioning so the page can scroll naturally.
- Save app/frontend/styles.css.
- Confirm that the file ends with two responsive media rules.
- Create app/frontend/app.js with the stage constants and page references below:
const TOTAL_STAGES = 9;
const FIRST_QUESTION = "What is your full name?";
const messages = document.querySelector("#messages");
const form = document.querySelector("#chat-form");
const input = document.querySelector("#message-input");
const sendButton = document.querySelector("#send-button");
const resetButton = document.querySelector("#reset-button");
const progressBar = document.querySelector("#progress-bar");
const progressLabel = document.querySelector("#progress-label");
const status = document.querySelector("#status");
const transferStatus = document.querySelector("#transfer-status");
const leadDetails = document.querySelector("#lead-details");
const decisionSection = document.querySelector("#decision-section");
const requirementSummary = document.querySelector("#requirement-summary");
const appropriateSolution = document.querySelector("#appropriate-solution");
const recommendationSection = document.querySelector("#recommendation-section");
const recommendation = document.querySelector("#recommendation");
const sourcesSection = document.querySelector("#sources-section");
const sourcesList = document.querySelector("#sources-list");
const downloadButton = document.querySelector("#download-button");
Why cache these page references?
Each reference points to one region created in index.html. The browser client can update those regions without reloading the page.
- Add the prospect labels and initial state below the page references:
const labels = {
contact_name: "Contact name",
company: "Company",
work_email: "Work email",
phone: "Phone",
challenge: "Challenge",
requirement_detail: "Requirement detail",
role: "Decision role",
timeline: "Timeline",
budget: "Budget readiness",
};
let state = createInitialState();
function createInitialState() {
return {
stage: 0,
answers: {},
requirementSummary: "",
appropriateSolution: "",
recommendation: "",
qualificationStatus: "Collecting qualification details",
transferStatus: "Not evaluated",
sources: [],
complete: false,
};
}
What does the browser state remember?
The state object mirrors the API response fields. Resetting the conversation replaces it with the same predictable starting values.
- Add the message, progress, and prospect-detail renderers below createInitialState().
function appendMessage(role, text) {
const bubble = document.createElement("div");
bubble.className = `message ${role}`;
bubble.textContent = text;
messages.appendChild(bubble);
messages.scrollTop = messages.scrollHeight;
}
function updateProgress(completedStages) {
const percentage = Math.round((completedStages / TOTAL_STAGES) * 100);
progressBar.style.width = `${percentage}%`;
progressLabel.textContent = `Qualification progress: ${percentage}%`;
}
function renderLeadDetails() {
leadDetails.replaceChildren();
for (const [key, label] of Object.entries(labels)) {
if (!state.answers[key]) continue;
const row = document.createElement("div");
const term = document.createElement("dt");
const value = document.createElement("dd");
term.textContent = label;
value.textContent = state.answers[key];
row.append(term, value);
leadDetails.appendChild(row);
}
}
How are answers rendered safely?
The client assigns prospect content through textContent. Each stored answer becomes a labeled row in the summary card.
Progress is calculated from the completed stage count. The first accepted answer therefore moves the bar to roughly one ninth.
- Add the source renderer below renderLeadDetails().
function renderSources() {
sourcesList.replaceChildren();
for (const source of state.sources) {
const item = document.createElement("li");
const link = document.createElement("a");
link.href = source.url;
link.target = "_blank";
link.rel = "noreferrer";
link.textContent = source.title;
item.appendChild(link);
sourcesList.appendChild(item);
}
sourcesSection.hidden = state.sources.length === 0;
}
How will verified sources appear?
The renderer creates one external link for each source returned by the backend. The entire source section stays hidden while the temporary API returns an empty list.
- Add the completion renderer below renderSources().
function renderCompletion() {
status.textContent = state.qualificationStatus;
renderLeadDetails();
if (!state.complete) return;
transferStatus.textContent = state.transferStatus;
transferStatus.hidden = false;
requirementSummary.textContent = state.requirementSummary;
appropriateSolution.textContent = state.appropriateSolution;
decisionSection.hidden = false;
recommendation.textContent = state.recommendation;
recommendationSection.hidden = false;
renderSources();
downloadButton.hidden = false;
form.hidden = true;
updateProgress(TOTAL_STAGES);
}
When does the result card expand?
Partial responses update the visible prospect details and status. A complete response reveals the decision, recommendation, transfer result, sources, and download control.
- Add the API request function below renderCompletion().
async function submitAnswer(message) {
const response = await fetch("/api/chat", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({stage: state.stage, message, answers: state.answers}),
});
if (!response.ok) throw new Error(`API request failed with status ${response.status}`);
return response.json();
}
What crosses the API boundary?
Each request contains the current stage, the latest message, and the existing answer map. The function returns the validated JSON response for the interface to render.
- Add the form submission handler below submitAnswer().
form.addEventListener("submit", async (event) => {
event.preventDefault();
const message = input.value.trim();
if (!message) return;
appendMessage("user", message);
input.value = "";
sendButton.disabled = true;
try {
const data = await submitAnswer(message);
state.stage = data.stage;
state.answers = data.answers;
state.requirementSummary = data.requirement_summary;
state.appropriateSolution = data.appropriate_solution;
state.qualificationStatus = data.qualification_status;
state.transferStatus = data.transfer_status;
state.sources = data.sources;
state.complete = data.complete;
state.recommendation = data.complete ? data.assistant_message : "";
appendMessage("assistant", data.assistant_message);
updateProgress(data.complete ? TOTAL_STAGES : data.stage);
renderCompletion();
} catch (error) {
appendMessage("assistant", "The local application could not process that answer. Check the FastAPI and Ollama terminals, then try again.");
console.error(error);
} finally {
sendButton.disabled = false;
input.focus();
}
});
How does one answer update the page?
The handler displays the prospect message before calling the API. A successful response updates the shared state and adds the next assistant question.
The send button remains disabled during the request. The error path leaves a visible recovery message in the conversation.
- Add the reset handler below the form submission handler:
resetButton.addEventListener("click", () => {
state = createInitialState();
messages.replaceChildren();
leadDetails.replaceChildren();
decisionSection.hidden = true;
recommendationSection.hidden = true;
sourcesSection.hidden = true;
transferStatus.hidden = true;
downloadButton.hidden = true;
form.hidden = false;
status.textContent = state.qualificationStatus;
updateProgress(0);
appendMessage("assistant", FIRST_QUESTION);
input.focus();
});
What does reset restore?
Reset clears every rendered answer and result. It restores the first question with zero progress so another prospect can start cleanly.
- Add the JSON download behavior and initial rendering at the bottom of app.js.
downloadButton.addEventListener("click", () => {
const prospect = {
answers: state.answers,
requirement_summary: state.requirementSummary,
appropriate_solution: state.appropriateSolution,
qualification_status: state.qualificationStatus,
transfer_status: state.transferStatus,
recommendation: state.recommendation,
sources: state.sources,
exported_at: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(prospect, null, 2)], {type: "application/json"});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "mts-qualified-prospect.json";
link.click();
URL.revokeObjectURL(url);
});
appendMessage("assistant", FIRST_QUESTION);
updateProgress(0);
input.focus();
What does the final browser code add?
The download handler packages the visible prospect result as formatted JSON. The final three lines render the first question and focus the answer box when the page loads.
- Save app/frontend/app.js.
- Confirm that index.html, styles.css, and app.js appear under app/frontend.
Seeing JavaScript problem markers?
- Check that each function ends with the closing brace shown in its code block.
- Confirm that every selector begins with the same element ID used in index.html.
Ask for help: Help me compare my app.js element selectors and closing braces with the conversation shell code.
Serve and exercise the vertical slice
The backend and frontend now have matching message fields. The last connection serves the page from FastAPI and exposes its static assets under one origin.
- At the bottom of app/main.py, add the page route and static-file mount:
@app.get("/", response_class=FileResponse)
def index() -> FileResponse:
return FileResponse(str(FRONTEND_DIR / "index.html"))
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
How does FastAPI serve the browser files?
The root route returns index.html as a file response. The static mount makes the linked stylesheet and browser script available under /static.
Serving both layers from the same application keeps this local vertical slice on one address. The browser can call /api/chat directly.
- Save app/main.py.
- Return to the terminal running FastAPI.
- Confirm that the development server reports a reload without an import error.
✔️ Awesome, I've got everything!
Great. Save every file before testing the page in your browser.
ⓧ I'd like to double check the full code
Compare each file below with the corresponding file in your project.
"""MTS AI sales qualification assistant."""
This package file should contain the single module description shown above.
QUALIFICATION_FIELDS = (
("contact_name", "What is your full name?"),
("company", "What company or organization do you represent?"),
("work_email", "What work email should the sales team use?"),
("phone", "What phone number should the sales team use?"),
("challenge", "What energy, carbon, or sustainability challenge would you like to solve?"),
("requirement_detail", "Please add one detail about the outcome or scope you need."),
("role", "Choose your role: decision maker, evaluator, or researcher."),
("timeline", "Choose a timeline: 0-3 months, 3-6 months, or 6+ months."),
("budget", "Choose the budget readiness: exploring, planning, or approved."),
)
This file should contain all nine temporary conversation stages.
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from app.sales_qualification import QUALIFICATION_FIELDS
FRONTEND_DIR = Path(__file__).resolve().parent / "frontend"
class ChatRequest(BaseModel):
stage: int
message: str
answers: dict[str, str]
class Source(BaseModel):
title: str
url: str
class ChatResponse(BaseModel):
stage: int
assistant_message: str
complete: bool
answers: dict[str, str]
requirement_summary: str
appropriate_solution: str
qualification_status: str
transfer_status: str
sources: list[Source]
app = FastAPI(title="MTS AI Sales Qualification Assistant", version="1.0.0")
retriever = None
qualification_engine = None
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok", "model": "gemma3:1b"}
@app.post("/api/chat", response_model=ChatResponse)
def chat(request: ChatRequest) -> ChatResponse:
stage = max(0, min(request.stage, len(QUALIFICATION_FIELDS) - 1))
answers = dict(request.answers)
answer_key, _ = QUALIFICATION_FIELDS[stage]
answers[answer_key] = request.message.strip()
next_stage = min(stage + 1, len(QUALIFICATION_FIELDS))
complete = next_stage == len(QUALIFICATION_FIELDS)
assistant_message = (
"Collecting qualification details"
if complete
else QUALIFICATION_FIELDS[next_stage][1]
)
return ChatResponse(
stage=next_stage,
assistant_message=assistant_message,
complete=complete,
answers=answers,
requirement_summary="",
appropriate_solution="",
qualification_status="Collecting qualification details",
transfer_status="Not evaluated",
sources=[],
)
@app.get("/", response_class=FileResponse)
def index() -> FileResponse:
return FileResponse(str(FRONTEND_DIR / "index.html"))
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
This backend file should define the models, temporary routes, root response, and static mount shown above.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MTS AI Sales Qualification Assistant</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<main class="app-shell">
<section class="hero">
<p class="eyebrow">MTS Energy and Carbon Audit Services</p>
<h1>AI Sales Qualification Assistant</h1>
<p>Describe your challenge and let the qualification engine identify the right MTS solution.</p>
</section>
<section class="workspace">
<article class="chat-card">
<div class="progress-row">
<span id="progress-label">Qualification progress: 0%</span>
<button id="reset-button" class="text-button" type="button">Reset</button>
</div>
<div class="progress-track" aria-hidden="true">
<div id="progress-bar" class="progress-bar"></div>
</div>
<div id="messages" class="messages" aria-live="polite"></div>
<form id="chat-form" class="chat-form">
<label for="message-input">Your answer</label>
<div class="input-row">
<textarea id="message-input" rows="3" required placeholder="Type your response"></textarea>
<button id="send-button" type="submit">Send</button>
</div>
</form>
</article>
<aside class="lead-card">
<p class="eyebrow">Qualification result</p>
<h2>Prospect summary</h2>
<p id="status" class="status">Collecting qualification details</p>
<p id="transfer-status" class="transfer-status" hidden></p>
<dl id="lead-details" class="lead-details"></dl>
<section id="decision-section" hidden>
<h3>Identified requirement</h3>
<p id="requirement-summary"></p>
<h3>Appropriate MTS solution</h3>
<p id="appropriate-solution"></p>
</section>
<section id="recommendation-section" hidden>
<h3>Grounded explanation</h3>
<p id="recommendation"></p>
</section>
<section id="sources-section" hidden>
<h3>Verified MTS sources</h3>
<ul id="sources-list"></ul>
</section>
<button id="download-button" class="secondary-button" type="button" hidden>
Download prospect JSON
</button>
</aside>
</section>
</main>
<script src="/static/app.js" defer></script>
</body>
</html>
This page should contain both cards and every result region used by the browser script.
:root {
color-scheme: light;
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f3f7f5;
color: #17332a;
--green-900: #173f32;
--green-700: #246b4f;
--green-500: #3b936c;
--green-100: #dff0e8;
--line: #d8e4de;
--white: #ffffff;
--shadow: 0 18px 50px rgba(23, 63, 50, 0.12);
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; }
button, textarea { font: inherit; }
button { cursor: pointer; }
.app-shell { width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: 40px 0 64px; }
.hero { max-width: 760px; margin-bottom: 28px; }
.eyebrow { margin: 0 0 8px; color: var(--green-700); font-size: 0.78rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
h1, h2, h3, p { margin-top: 0; }
h1 { margin-bottom: 12px; font-size: clamp(2rem, 5vw, 3.7rem); line-height: 1.02; }
.hero > p:last-child { color: #4e665d; font-size: 1.08rem; line-height: 1.7; }
.workspace { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(300px, 0.9fr); gap: 24px; }
.chat-card, .lead-card { border: 1px solid var(--line); border-radius: 22px; background: var(--white); box-shadow: var(--shadow); }
.chat-card { padding: 24px; }
.lead-card { align-self: start; padding: 24px; position: sticky; top: 20px; }
.progress-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: #52685f; font-size: 0.9rem; }
.text-button { border: 0; background: transparent; color: var(--green-700); font-weight: 700; }
.progress-track { height: 8px; margin: 10px 0 24px; overflow: hidden; border-radius: 999px; background: var(--green-100); }
.progress-bar { width: 0; height: 100%; background: var(--green-500); transition: width 180ms ease; }
.messages { display: flex; min-height: 360px; max-height: 520px; flex-direction: column; gap: 14px; overflow-y: auto; padding: 4px 4px 20px; }
.message { max-width: 82%; padding: 13px 15px; border-radius: 16px; line-height: 1.55; white-space: pre-wrap; }
.message.assistant { align-self: flex-start; border-bottom-left-radius: 4px; background: var(--green-100); }
.message.user { align-self: flex-end; border-bottom-right-radius: 4px; background: var(--green-900); color: var(--white); }
.chat-form { padding-top: 20px; border-top: 1px solid var(--line); }
.chat-form label { display: block; margin-bottom: 8px; font-size: 0.88rem; font-weight: 700; }
.input-row { display: grid; grid-template-columns: 1fr auto; gap: 10px; }
textarea { width: 100%; resize: vertical; border: 1px solid #b9cdc4; border-radius: 12px; padding: 12px; color: inherit; }
textarea:focus { outline: 3px solid rgba(59, 147, 108, 0.18); border-color: var(--green-500); }
#send-button, .secondary-button { border: 0; border-radius: 12px; padding: 12px 18px; background: var(--green-700); color: var(--white); font-weight: 800; }
#send-button:disabled { cursor: wait; opacity: 0.6; }
.status { display: inline-block; border-radius: 999px; padding: 7px 11px; background: var(--green-100); color: var(--green-900); font-size: 0.85rem; font-weight: 800; }
.transfer-status { margin: 12px 0 0; color: #4e665d; line-height: 1.5; }
.lead-details { display: grid; gap: 14px; margin: 22px 0; }
.lead-details div { padding-bottom: 12px; border-bottom: 1px solid var(--line); }
.lead-details dt { color: #60746c; font-size: 0.78rem; font-weight: 800; text-transform: uppercase; }
.lead-details dd { margin: 5px 0 0; line-height: 1.45; }
#decision-section, #recommendation, #requirement-summary { line-height: 1.65; }
#sources-list { padding-left: 20px; }
#sources-list a { color: var(--green-700); font-weight: 700; }
.secondary-button { width: 100%; margin-top: 12px; background: var(--green-900); }
@media (max-width: 860px) { .workspace { grid-template-columns: 1fr; } .lead-card { position: static; } }
@media (max-width: 560px) { .app-shell { width: min(100% - 20px, 1180px); padding-top: 24px; } .chat-card, .lead-card { border-radius: 16px; padding: 18px; } .input-row { grid-template-columns: 1fr; } .message { max-width: 92%; } }
This stylesheet should end with the two mobile layout rules shown above.
const TOTAL_STAGES = 9;
const FIRST_QUESTION = "What is your full name?";
const messages = document.querySelector("#messages");
const form = document.querySelector("#chat-form");
const input = document.querySelector("#message-input");
const sendButton = document.querySelector("#send-button");
const resetButton = document.querySelector("#reset-button");
const progressBar = document.querySelector("#progress-bar");
const progressLabel = document.querySelector("#progress-label");
const status = document.querySelector("#status");
const transferStatus = document.querySelector("#transfer-status");
const leadDetails = document.querySelector("#lead-details");
const decisionSection = document.querySelector("#decision-section");
const requirementSummary = document.querySelector("#requirement-summary");
const appropriateSolution = document.querySelector("#appropriate-solution");
const recommendationSection = document.querySelector("#recommendation-section");
const recommendation = document.querySelector("#recommendation");
const sourcesSection = document.querySelector("#sources-section");
const sourcesList = document.querySelector("#sources-list");
const downloadButton = document.querySelector("#download-button");
const labels = {
contact_name: "Contact name",
company: "Company",
work_email: "Work email",
phone: "Phone",
challenge: "Challenge",
requirement_detail: "Requirement detail",
role: "Decision role",
timeline: "Timeline",
budget: "Budget readiness",
};
let state = createInitialState();
function createInitialState() {
return {
stage: 0,
answers: {},
requirementSummary: "",
appropriateSolution: "",
recommendation: "",
qualificationStatus: "Collecting qualification details",
transferStatus: "Not evaluated",
sources: [],
complete: false,
};
}
function appendMessage(role, text) {
const bubble = document.createElement("div");
bubble.className = `message ${role}`;
bubble.textContent = text;
messages.appendChild(bubble);
messages.scrollTop = messages.scrollHeight;
}
function updateProgress(completedStages) {
const percentage = Math.round((completedStages / TOTAL_STAGES) * 100);
progressBar.style.width = `${percentage}%`;
progressLabel.textContent = `Qualification progress: ${percentage}%`;
}
function renderLeadDetails() {
leadDetails.replaceChildren();
for (const [key, label] of Object.entries(labels)) {
if (!state.answers[key]) continue;
const row = document.createElement("div");
const term = document.createElement("dt");
const value = document.createElement("dd");
term.textContent = label;
value.textContent = state.answers[key];
row.append(term, value);
leadDetails.appendChild(row);
}
}
function renderSources() {
sourcesList.replaceChildren();
for (const source of state.sources) {
const item = document.createElement("li");
const link = document.createElement("a");
link.href = source.url;
link.target = "_blank";
link.rel = "noreferrer";
link.textContent = source.title;
item.appendChild(link);
sourcesList.appendChild(item);
}
sourcesSection.hidden = state.sources.length === 0;
}
function renderCompletion() {
status.textContent = state.qualificationStatus;
renderLeadDetails();
if (!state.complete) return;
transferStatus.textContent = state.transferStatus;
transferStatus.hidden = false;
requirementSummary.textContent = state.requirementSummary;
appropriateSolution.textContent = state.appropriateSolution;
decisionSection.hidden = false;
recommendation.textContent = state.recommendation;
recommendationSection.hidden = false;
renderSources();
downloadButton.hidden = false;
form.hidden = true;
updateProgress(TOTAL_STAGES);
}
async function submitAnswer(message) {
const response = await fetch("/api/chat", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({stage: state.stage, message, answers: state.answers}),
});
if (!response.ok) throw new Error(`API request failed with status ${response.status}`);
return response.json();
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const message = input.value.trim();
if (!message) return;
appendMessage("user", message);
input.value = "";
sendButton.disabled = true;
try {
const data = await submitAnswer(message);
state.stage = data.stage;
state.answers = data.answers;
state.requirementSummary = data.requirement_summary;
state.appropriateSolution = data.appropriate_solution;
state.qualificationStatus = data.qualification_status;
state.transferStatus = data.transfer_status;
state.sources = data.sources;
state.complete = data.complete;
state.recommendation = data.complete ? data.assistant_message : "";
appendMessage("assistant", data.assistant_message);
updateProgress(data.complete ? TOTAL_STAGES : data.stage);
renderCompletion();
} catch (error) {
appendMessage("assistant", "The local application could not process that answer. Check the FastAPI and Ollama terminals, then try again.");
console.error(error);
} finally {
sendButton.disabled = false;
input.focus();
}
});
resetButton.addEventListener("click", () => {
state = createInitialState();
messages.replaceChildren();
leadDetails.replaceChildren();
decisionSection.hidden = true;
recommendationSection.hidden = true;
sourcesSection.hidden = true;
transferStatus.hidden = true;
downloadButton.hidden = true;
form.hidden = false;
status.textContent = state.qualificationStatus;
updateProgress(0);
appendMessage("assistant", FIRST_QUESTION);
input.focus();
});
downloadButton.addEventListener("click", () => {
const prospect = {
answers: state.answers,
requirement_summary: state.requirementSummary,
appropriate_solution: state.appropriateSolution,
qualification_status: state.qualificationStatus,
transfer_status: state.transferStatus,
recommendation: state.recommendation,
sources: state.sources,
exported_at: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(prospect, null, 2)], {type: "application/json"});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "mts-qualified-prospect.json";
link.click();
URL.revokeObjectURL(url);
});
appendMessage("assistant", FIRST_QUESTION);
updateProgress(0);
input.focus();
This browser file should finish by rendering the first question and focusing the answer box.
- Open http://127.0.0.1:8000 in your browser.
- Confirm that the page shows the first contact question, an empty progress bar, and an enabled answer box.
Before you submit your name, what should change if the browser and API are connected correctly?
- Enter Jordan Lee in the answer box.
- Click Send.
You'll see the progress change to 11%. The company question appears immediately without a page reload.
That's the first product loop working: the browser sends a validated answer and renders the backend's next question.
Page loads but the answer does not advance?
- Confirm that the FastAPI terminal remains running while you use the page.
- Check that app.js sends its request to /api/chat.
- Confirm that the browser address starts with http://127.0.0.1:8000.
Ask for help: Help me debug why my FastAPI conversation page does not advance after submitting the first answer.
Your conversation shell now carries prospect answers from the browser to FastAPI. Next, you'll connect the local model and expose why a plausible recommendation still needs verified grounding.
Connect the Local LLM
Your FastAPI shell already collects prospect answers through the browser. It now needs a local model connection that can turn the completed profile into a recommendation.
In this step, you will connect the app to Ollama on the Windows host. You will intentionally let the local LLM recommend a service from the profile alone so you can inspect the missing evidence.
In this step, get ready to:
- Create a local Ollama client with host and container-ready addresses.
- Send the completed nine-field prospect profile to the local model.
- Inspect the generic recommendation and identify its grounding gap.
Create the Ollama client
The client sends a chat request to Ollama through its local HTTP endpoint. It tries the Windows address first so the current development server can reach the model.
- In the VS Code Explorer sidebar, create an ai folder inside app.
- Create app/ai/__init__.py inside the new folder.
- Add this package description to app/ai/__init__.py:
"""Retrieval and local LLM components."""
Why create this file?
The __init__.py file makes app.ai an importable Python package. The backend can now import the model client from app.ai.assistant.
- Save app/ai/__init__.py.
- Create app/ai/assistant.py inside the same ai folder.
- Add the imports and Ollama settings to app/ai/assistant.py by copying this code:
import json
from urllib.error import URLError
from urllib.request import Request, urlopen
# Target the Windows host before using Docker Desktop's host address
MODEL = "gemma3:1b"
OLLAMA_CHAT_URLS = (
"http://localhost:11434/api/chat",
"http://host.docker.internal:11434/api/chat",
)
What do these settings control?
- The MODEL constant keeps the verified gemma3:1b model ID in one place.
- The first URL reaches Ollama while FastAPI runs directly on Windows.
- The second URL prepares the same client for Docker Desktop networking later in the project.
- Save app/ai/assistant.py.
- Confirm both files appear inside app/ai in the VS Code Explorer sidebar.
Missing the AI package?
Check that the folder path is app/ai. Make sure neither file was created inside app/frontend.
Ask for help: Help me check why Python cannot find my app.ai package in this FastAPI project.
The request helper converts a Python dictionary into JSON before sending it to Ollama. It also reads the generated text from the response.
- Add this request helper below OLLAMA_CHAT_URLS in app/ai/assistant.py by copying the code:
def _post_to_ollama(url: str, payload: dict) -> str:
# Encode the chat request for Ollama's HTTP endpoint
encoded_payload = json.dumps(payload).encode("utf-8")
request = Request(
url,
data=encoded_payload,
headers={"Content-Type": "application/json"},
method="POST",
)
# Read the generated assistant message from the response
with urlopen(request, timeout=120) as response:
body = json.loads(response.read().decode("utf-8"))
return body["message"]["content"].strip()
What does this helper do?
- The payload becomes encoded JSON that the chat endpoint can read.
- The request uses an HTTP POST because the profile and model settings belong in the request body.
- The helper returns only message.content so the API receives the generated recommendation as plain text.
- Save app/ai/assistant.py.
- Check the VS Code Problems panel for Python syntax errors.
The main client builds the model conversation and tries both addresses in order. A failed Windows request therefore leaves a supported route for the future container.
- Add _call_ollama() below _post_to_ollama() by copying this code:
def _call_ollama(prompt: str) -> str:
# Give the model a profile-only recommendation task
payload = {
"model": MODEL,
"messages": [
{
"role": "system",
"content": "You write concise MTS sales recommendations.",
},
{"role": "user", "content": prompt},
],
"stream": False,
}
last_error: Exception | None = None
# Try each supported host address before reporting failure
for url in OLLAMA_CHAT_URLS:
try:
return _post_to_ollama(url, payload)
except (URLError, TimeoutError, json.JSONDecodeError, KeyError) as error:
last_error = error
raise RuntimeError("Ollama could not be reached") from last_error
How does the model call work?
- The model field selects the local gemma3:1b model.
- The messages field gives Ollama a system instruction followed by the prospect prompt.
- The stream value is False so the backend receives one complete response.
- The loop raises one clear local error after both addresses fail.
The temporary recommendation function gives the model only the collected profile. This creates the limitation you will inspect before adding verified service records.
- Add the profile-only recommendation function below _call_ollama() by copying this code:
def create_profile_recommendation(answers: dict[str, str]) -> str:
# Keep this first draft limited to the collected prospect profile
profile = json.dumps(answers, indent=2)
prompt = f"""
Recommend the most appropriate MTS service for this prospect.
Explain the recommendation in a concise paragraph.
PROSPECT PROFILE
{profile}
""".strip()
try:
return _call_ollama(prompt)
except RuntimeError:
return "Ollama could not be reached. Keep Ollama running, then try again."
Why is this recommendation ungrounded?
The prompt contains the prospect's answers without any verified MTS service records. The model can produce fluent text from that profile while providing no source for its service selection.
The fallback keeps a local connection failure visible in the browser. It prevents an unreachable model from becoming an unexplained server error.
- Save app/ai/assistant.py.
- Confirm the VS Code Problems panel shows no syntax errors for the file.
Seeing Python errors in the client?
- Check that every closing parenthesis matches the opening parenthesis in OLLAMA_CHAT_URLS.
- Confirm _post_to_ollama() appears above _call_ollama().
- Verify that create_profile_recommendation() appears after both request functions.
Ask for help: Help me find the syntax or ordering problem in my local Ollama client.
✔️ Awesome, I've got everything!
Your local model client is ready. Double check that both files are saved before connecting it to the API.
ⓧ I'd like to double check the full code
Compare both files in app/ai with these complete versions.
"""Retrieval and local LLM components."""
import json
from urllib.error import URLError
from urllib.request import Request, urlopen
# Target the Windows host before using Docker Desktop's host address
MODEL = "gemma3:1b"
OLLAMA_CHAT_URLS = (
"http://localhost:11434/api/chat",
"http://host.docker.internal:11434/api/chat",
)
def _post_to_ollama(url: str, payload: dict) -> str:
# Encode the chat request for Ollama's HTTP endpoint
encoded_payload = json.dumps(payload).encode("utf-8")
request = Request(
url,
data=encoded_payload,
headers={"Content-Type": "application/json"},
method="POST",
)
# Read the generated assistant message from the response
with urlopen(request, timeout=120) as response:
body = json.loads(response.read().decode("utf-8"))
return body["message"]["content"].strip()
def _call_ollama(prompt: str) -> str:
# Give the model a profile-only recommendation task
payload = {
"model": MODEL,
"messages": [
{
"role": "system",
"content": "You write concise MTS sales recommendations.",
},
{"role": "user", "content": prompt},
],
"stream": False,
}
last_error: Exception | None = None
# Try each supported host address before reporting failure
for url in OLLAMA_CHAT_URLS:
try:
return _post_to_ollama(url, payload)
except (URLError, TimeoutError, json.JSONDecodeError, KeyError) as error:
last_error = error
raise RuntimeError("Ollama could not be reached") from last_error
def create_profile_recommendation(answers: dict[str, str]) -> str:
# Keep this first draft limited to the collected prospect profile
profile = json.dumps(answers, indent=2)
prompt = f"""
Recommend the most appropriate MTS service for this prospect.
Explain the recommendation in a concise paragraph.
PROSPECT PROFILE
{profile}
""".strip()
try:
return _call_ollama(prompt)
except RuntimeError:
return "Ollama could not be reached. Keep Ollama running, then try again."
Connect profile completion to the model
The API already records each answer and returns the next generic question. Only the completion path needs to call the model after the budget answer fills the ninth profile field.
- Switch back to app/main.py in VS Code.
- Add this import below the existing third-party imports:
from app.ai.assistant import MODEL, create_profile_recommendation
What does this import provide?
The API uses MODEL for its health response. It uses create_profile_recommendation() only when the browser has supplied all nine answers.
- Find the completion response at the bottom of the existing chat() function.
- Replace that completion response with this profile-only model call:
# Ask the local model only after all nine profile fields are present
recommendation = create_profile_recommendation(answers)
return ChatResponse(
stage=len(QUALIFICATION_FIELDS),
assistant_message=recommendation,
complete=True,
answers=answers,
requirement_summary="",
appropriate_solution="",
qualification_status="Profile complete",
transfer_status="Not evaluated",
sources=[],
)
What changes at completion?
- The collected answers dictionary becomes the model's only decision context.
- The generated text becomes assistant_message so the existing JavaScript client can display it.
- The requirement and solution values remain empty because no qualification engine has identified either one.
- The empty sources list keeps the verified sources region hidden.
- Save app/main.py.
- Switch back to the PowerShell terminal running the development server from earlier.
FastAPI reloads the saved application. You should see the development server remain available without an import traceback.
Did the development server stop?
Check that the virtual environment from earlier is still active in the project-root PowerShell terminal. Start the development server again if the earlier process was closed.
- Restart the development server by running:
fastapi dev
This command loads the FastAPI entry point from pyproject.toml and starts the local development server. Keep this terminal running while you test the browser flow.
Ask for help: Help me debug why FastAPI cannot import my local Ollama assistant module.
✔️ The server reloaded successfully
Your API can import the local model client. Keep the development server running for the browser test.
ⓧ I'd like to double check the full code
Compare your cumulative app/main.py file with this version.
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from app.ai.assistant import MODEL, create_profile_recommendation
from app.sales_qualification import QUALIFICATION_FIELDS
FRONTEND_DIR = Path(__file__).resolve().parent / "frontend"
class ChatRequest(BaseModel):
stage: int
message: str
answers: dict[str, str]
class Source(BaseModel):
title: str
url: str
class ChatResponse(BaseModel):
stage: int
assistant_message: str
complete: bool
answers: dict[str, str]
requirement_summary: str
appropriate_solution: str
qualification_status: str
transfer_status: str
sources: list[Source]
app = FastAPI(title="MTS AI Sales Qualification Assistant", version="1.0.0")
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok", "model": MODEL}
@app.post("/api/chat", response_model=ChatResponse)
def chat(request: ChatRequest) -> ChatResponse:
stage = max(0, min(request.stage, len(QUALIFICATION_FIELDS) - 1))
answers = dict(request.answers)
answer_key, _ = QUALIFICATION_FIELDS[stage]
answers[answer_key] = request.message.strip()
if stage < len(QUALIFICATION_FIELDS) - 1:
next_stage = stage + 1
_, next_question = QUALIFICATION_FIELDS[next_stage]
return ChatResponse(
stage=next_stage,
assistant_message=next_question,
complete=False,
answers=answers,
requirement_summary="",
appropriate_solution="",
qualification_status="Collecting qualification details",
transfer_status="Not evaluated",
sources=[],
)
# Ask the local model only after all nine profile fields are present
recommendation = create_profile_recommendation(answers)
return ChatResponse(
stage=len(QUALIFICATION_FIELDS),
assistant_message=recommendation,
complete=True,
answers=answers,
requirement_summary="",
appropriate_solution="",
qualification_status="Profile complete",
transfer_status="Not evaluated",
sources=[],
)
@app.get("/", response_class=FileResponse)
def index() -> FileResponse:
return FileResponse(str(FRONTEND_DIR / "index.html"))
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
Run the ungrounded conversation
The browser can now collect the complete profile and display text generated by the local model. The fixed requirement-detail question still ignores which service the challenge may need.
- Switch back to the browser page at http://127.0.0.1:8000.
- Select the reset control to begin with an empty profile.
- Submit Alex Morgan as the contact name.
- Submit Northstar Manufacturing as the company.
- Submit alex@northstar.example as the work email.
- Submit 555-0100 as the phone number.
- Submit We need to reduce industrial energy use across our facility. as the challenge.
You will see Please add one detail about the outcome or scope you need. as the next question. The question stays generic even though the challenge concerns industrial energy use.
- Submit We want boilers motors and compressed air assessed. as the requirement detail.
- Submit decision maker as the role.
- Submit 0-3 months as the timeline.
Before you submit the last answer, predict whether the model can support its recommendation with verified evidence from the profile alone.
- Submit planning as the budget readiness.
The model may take a moment because it is generating locally. You will see a recommendation in the conversation once Ollama finishes.
The prospect summary now contains all nine answers. The identified requirement and appropriate solution regions remain empty while the verified sources region stays hidden.
This shortfall is intentional
The recommendation sounds plausible because the model can write from the prospect profile. No retrieved MTS record proves that the named service is the strongest match.
The generic follow-up also fails to discover service-specific detail. These two gaps show why generated prose cannot act as the qualification decision.
No recommendation appearing?
- Confirm Ollama remains running on the Windows host.
- Confirm gemma3:1b is still available in Ollama.
- Check the FastAPI terminal for a Python traceback after the budget answer.
Ask for help: Help me trace why my completed profile is not reaching the local Ollama model.
The local model connection is working. You have also exposed the exact evidence gap that prevents this draft from making a trustworthy sales decision.
Your browser now reaches gemma3:1b through the FastAPI backend. Next, you will replace the fixed question and unsupported service choice with an adaptive qualification engine built from verified MTS records.
Build the Adaptive Qualification Engine
Your browser workflow now collects a complete prospect profile. The previous test showed that a generic question leaves the local LLM free to recommend a service without verified evidence.
This step adds an auditable retrieval-augmented generation layer. Deterministic rules will identify the requirement. The same rules will select the MTS solution before Ollama explains the decision.
In this step, get ready to:
- Create verified MTS service records with service-specific qualification questions.
- Build a swappable keyword retriever with deterministic qualification rules.
- Ground the generated explanation in the selected service records.
Create the verified service catalog
Each service record keeps its name, scope, matching keywords, qualification question, and official source together. This gives the engine one auditable place to retrieve both the next question and the possible solution.
Why keyword retrieval first?
Keyword retrieval makes every match visible in a small curated catalog. Title matches receive extra weight. Description matches provide supporting evidence.
The Retriever interface keeps the engine independent from this implementation. An embedding retriever can later replace it without changing the API or browser.
- Switch back to the MTS project in VS Code.
- Replace the contents of app/__init__.py with this package description:
"""MTS AI sales qualification assistant."""
What does this line do?
The module docstring identifies the purpose of the main application package. It gives Python tools a concise description of the package.
- Replace the contents of app/ai/__init__.py with this package description:
"""Retrieval and local LLM components."""
What does this line do?
This docstring defines the role of the AI subpackage. The folder now has a clear boundary around retrieval and local model code.
- Create app/ai/knowledge.py by using the full-file reference in the second tab below:
✔️ Awesome, I've got everything!
Save app/ai/knowledge.py after confirming that it contains seven MTS service records.
ⓧ I'd like to double check the full code
from dataclasses import dataclass
MTS_SOURCE_URL = "https://www.mtsenergyandcarbon.com/"
@dataclass(frozen=True)
class ServiceDocument:
name: str
description: str
keywords: tuple[str, ...]
qualification_question: str
source_url: str = MTS_SOURCE_URL
SERVICE_KNOWLEDGE = [
ServiceDocument(
name="ISO 50001 Implementation",
description=(
"MTS supports Energy Management Systems aligned with ISO 50001:2018, "
"including implementation, documentation, training, internal audits, "
"and continual improvement."
),
keywords=("energy management", "enms", "iso 50001", "documentation", "internal audit"),
qualification_question=(
"Do you need ISO 50001 implementation, documentation, training, "
"an internal audit, or continual-improvement support?"
),
),
ServiceDocument(
name="Training services",
description=(
"MTS provides training on Energy Management, ISO 50001, Carbon Accounting, "
"ESG, Steam System Management, and Sustainability practices."
),
keywords=("training", "workshop", "capacity building", "steam", "esg"),
qualification_question=(
"Which team needs training, and is the priority Energy Management, "
"ISO 50001, Carbon Accounting, ESG, Steam System Management, or Sustainability?"
),
),
ServiceDocument(
name="Energy Audit",
description=(
"MTS provides energy audits to identify energy-saving opportunities, "
"optimize utility performance, reduce operating costs, and improve efficiency."
),
keywords=(
"energy audit", "utility", "boiler", "turbine", "hvac", "motor",
"transformer", "pump", "compressed air", "power quality", "efficiency",
),
qualification_question=(
"Which systems need assessment: boilers, turbines, HVAC, motors, transformers, "
"pumps, compressed air, power quality, or the wider facility?"
),
),
ServiceDocument(
name="IIAS",
description=(
"Independent Implementation Assessment Services assess on-ground ISO-system "
"implementation at vendor and supplier locations."
),
keywords=("iias", "supplier", "vendor", "procurement", "implementation assessment"),
qualification_question=(
"Do you need independent verification of ISO-system implementation at vendor "
"or supplier locations, and how many locations are in scope?"
),
),
ServiceDocument(
name="GHG Accounting",
description=(
"MTS provides GHG Accounting and Carbon Footprint assessment aligned with "
"the GHG Protocol and ISO 14064, including Scope 1, 2, and 3 accounting."
),
keywords=("ghg", "carbon footprint", "scope 1", "scope 2", "scope 3", "emissions"),
qualification_question=(
"Do you need Scope 1, 2, or 3 accounting, carbon-footprint reporting, "
"data management, or climate planning?"
),
),
ServiceDocument(
name="EcoVadis Score Improvement Service",
description=(
"MTS supports EcoVadis preparedness through gap assessment, documentation, "
"corrective action planning, and a score improvement roadmap."
),
keywords=("ecovadis", "score", "gap assessment", "documentation", "corrective action"),
qualification_question=(
"Is the priority EcoVadis gap assessment, documentation support, corrective "
"action planning, or a score improvement roadmap?"
),
),
ServiceDocument(
name="Green Audit for Higher Educational Institutions",
description=(
"MTS provides an integrated Green Audit covering energy, water, waste, "
"GHG emissions, and climate action for higher educational institutions."
),
keywords=("green audit", "college", "university", "campus", "naac", "water", "waste"),
qualification_question=(
"Is this for a higher education campus, and which areas matter most: energy, "
"water, waste, GHG emissions, or climate action?"
),
),
]
How is the catalog structured?
- The frozen ServiceDocument dataclass prevents service records from being changed after creation.
- Each keywords tuple gives the retriever concrete language to compare with the prospect profile.
- Each qualification_question keeps the adaptive question tied to verified service scope.
- The default source_url attributes every record to the official MTS website.
- Save app/ai/knowledge.py.
- Confirm the SERVICE_KNOWLEDGE list contains seven ServiceDocument records.
The service catalog now keeps the matching evidence beside the question it unlocks. A retrieved service can drive the conversation without letting generated text invent the next step.
- Create app/ai/retriever.py by using the full-file reference in the second tab below:
✔️ Awesome, I've got everything!
Save app/ai/retriever.py after confirming that it defines the shared retriever interface and keyword implementation.
ⓧ I'd like to double check the full code
import re
from typing import Protocol
from app.ai.knowledge import ServiceDocument
STOP_WORDS = {
"a", "an", "and", "are", "for", "from", "in", "is", "it", "of",
"on", "our", "the", "to", "we", "with",
}
def _tokens(text: str) -> set[str]:
return {
token
for token in re.findall(r"[a-z0-9]+", text.lower())
if token not in STOP_WORDS and len(token) > 1
}
class Retriever(Protocol):
def retrieve(self, query: str, limit: int = 3) -> list[ServiceDocument]:
"""Return the records most relevant to a prospect profile."""
class KeywordRetriever:
def __init__(self, documents: list[ServiceDocument]):
self.documents = documents
def retrieve(self, query: str, limit: int = 3) -> list[ServiceDocument]:
query_tokens = _tokens(query)
scored: list[tuple[int, ServiceDocument]] = []
for document in self.documents:
title_tokens = _tokens(document.name)
body_tokens = _tokens(" ".join([document.description, *document.keywords]))
score = (3 * len(query_tokens & title_tokens)) + len(query_tokens & body_tokens)
if score > 0:
scored.append((score, document))
scored.sort(key=lambda item: item[0], reverse=True)
return [document for _, document in scored[:limit]]
How does retrieval work?
- The _tokens() helper converts text to lowercase terms. It removes common stop words that add little matching value.
- The Retriever protocol defines the interface that future retrieval implementations must follow.
- The title overlap receives three points per token. Description or keyword overlap receives one point per token.
- The final sort places the strongest matching service first. That first record becomes the engine's primary candidate.
- Save app/ai/retriever.py.
- Return to the terminal running FastAPI.
- Confirm the development server reloads without a Python traceback.
Seeing an import or syntax error?
- Check that knowledge.py and retriever.py are inside the existing app/ai folder.
- Check that every ServiceDocument( entry closes before the next record begins.
- Check that ServiceDocument uses the same capitalization in both files.
Ask for help: Help me compare my app/ai/knowledge.py and app/ai/retriever.py files with the expected imports and brackets.
Implement the deterministic qualification rules
The retriever proposes relevant records. The qualification engine turns those records into a requirement summary, a selected solution, and an explicit sales-readiness status.
The gate uses profile fields that the browser already collects. A matching service is required. The accepted role, timeline, and budget values remain visible in code.
- Replace app/sales_qualification.py by using the full-file reference in the second tab below:
✔️ Awesome, I've got everything!
Save app/sales_qualification.py after confirming that the generic stage list now feeds a deterministic engine.
ⓧ I'd like to double check the full code
from dataclasses import dataclass
from app.ai.knowledge import ServiceDocument
from app.ai.retriever import Retriever
QUALIFICATION_FIELDS = (
("contact_name", "What is your full name?"),
("company", "What company or organization do you represent?"),
("work_email", "What work email should the sales team use?"),
("phone", "What phone number should the sales team use?"),
("challenge", "What energy, carbon, or sustainability challenge would you like to solve?"),
("requirement_detail", "Please add one detail about the outcome or scope you need."),
("role", "Choose your role: decision maker, evaluator, or researcher."),
("timeline", "Choose a timeline: 0-3 months, 3-6 months, or 6+ months."),
("budget", "Choose the budget readiness: exploring, planning, or approved."),
)
@dataclass(frozen=True)
class QualificationResult:
requirement_summary: str
appropriate_solution: str
status: str
documents: list[ServiceDocument]
class SalesQualificationEngine:
def __init__(self, retriever: Retriever):
self.retriever = retriever
self.total_stages = len(QUALIFICATION_FIELDS)
def record_answer(
self,
stage: int,
message: str,
answers: dict[str, str],
) -> dict[str, str]:
updated = dict(answers)
answer_key, _ = QUALIFICATION_FIELDS[stage]
updated[answer_key] = message.strip()
return updated
def question_for_stage(self, stage: int, answers: dict[str, str]) -> str:
answer_key, default_question = QUALIFICATION_FIELDS[stage]
if answer_key != "requirement_detail":
return default_question
candidates = self.retriever.retrieve(answers.get("challenge", ""), limit=1)
if candidates:
return candidates[0].qualification_question
return default_question
def evaluate(self, answers: dict[str, str]) -> QualificationResult:
challenge = answers.get("challenge", "").strip()
detail = answers.get("requirement_detail", "").strip()
query = " ".join([challenge, detail, answers.get("company", "")])
documents = self.retriever.retrieve(query, limit=3)
requirement_summary = f"{challenge} Requirement detail: {detail}"
appropriate_solution = (
documents[0].name if documents else "Consultant clarification required"
)
role = answers.get("role", "").strip().lower()
timeline = answers.get("timeline", "").strip().lower()
budget = answers.get("budget", "").strip().lower()
qualified = (
bool(documents)
and role in {"decision maker", "evaluator"}
and timeline in {"0-3 months", "3-6 months"}
and budget in {"planning", "approved"}
)
status = (
"Qualified for sales follow-up"
if qualified
else "Needs further qualification"
)
return QualificationResult(
requirement_summary=requirement_summary,
appropriate_solution=appropriate_solution,
status=status,
documents=documents,
)
How does the engine make its decision?
- The record_answer() method maps the current stage to one profile field.
- The question_for_stage() method retrieves one service after the challenge. It returns that record's qualification question for requirement_detail.
- The evaluate() method retrieves again with the challenge, requirement detail, and company context.
- The gate approves only matched profiles with an accepted role, timeline, and budget value.
- Save app/sales_qualification.py.
- Return to the FastAPI terminal.
- Confirm the development server completes its reload without a traceback.
Why is the selected solution deterministic?
The first ranked ServiceDocument becomes appropriate_solution. The model does not choose or replace that value.
The qualification status also comes from fixed conditions. This keeps the routing decision inspectable before any generated explanation appears.
Does the server fail after reloading?
- Check that Retriever is imported from app.ai.retriever.
- Check that ServiceDocument is imported from app.ai.knowledge.
- Check the indentation inside qualified and QualificationResult.
Ask for help: Help me debug the qualification engine after FastAPI reloads with a Python error.
Ground the explanation and test the adaptive flow
The final integration gives the engine control over the selected solution. Ollama receives only the requirement summary and ranked verified records when it writes the human-facing explanation.
The existing browser already knows how to reveal decision fields and source links when the API returns values. Updating the backend activates those regions without changing the frontend files.
- Replace app/ai/assistant.py by using the full-file reference in the second tab below:
✔️ Awesome, I've got everything!
Keep app/ai/assistant.py open until you update the API. Save both integration files together.
ⓧ I'd like to double check the full code
import json
from urllib.error import URLError
from urllib.request import Request, urlopen
from app.ai.knowledge import ServiceDocument
MODEL = "gemma3:1b"
OLLAMA_CHAT_URLS = (
"http://localhost:11434/api/chat",
"http://host.docker.internal:11434/api/chat",
)
def _call_ollama(prompt: str) -> str:
payload = {
"model": MODEL,
"messages": [
{
"role": "system",
"content": (
"You explain verified MTS sales qualification decisions without "
"inventing commercial claims."
),
},
{"role": "user", "content": prompt},
],
"stream": False,
}
encoded_payload = json.dumps(payload).encode("utf-8")
last_error: Exception | None = None
for url in OLLAMA_CHAT_URLS:
request = Request(
url,
data=encoded_payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urlopen(request, timeout=120) as response:
body = json.loads(response.read().decode("utf-8"))
return body["message"]["content"].strip()
except (URLError, TimeoutError, json.JSONDecodeError, KeyError) as error:
last_error = error
raise RuntimeError("Ollama could not be reached") from last_error
def create_grounded_recommendation(
requirement_summary: str,
appropriate_solution: str,
documents: list[ServiceDocument],
) -> str:
if not documents:
return (
"No verified MTS solution could be selected. A consultant should clarify "
"the requirement before a recommendation or commercial claim is made."
)
context = "\n\n".join(
f"SERVICE: {document.name}\nDETAILS: {document.description}\nSOURCE: {document.source_url}"
for document in documents
)
prompt = f"""
The deterministic sales qualification engine selected this primary MTS solution:
{appropriate_solution}
Explain why it fits the identified requirement using only the verified context.
Do not replace the selected primary solution. Do not invent services, prices,
guaranteed savings, certifications, timelines, or capabilities. End with one
practical human follow-up action.
IDENTIFIED REQUIREMENT
{requirement_summary}
VERIFIED MTS CONTEXT
{context}
""".strip()
try:
return _call_ollama(prompt)
except RuntimeError:
return (
f"The qualification engine selected {appropriate_solution} for this "
"requirement. A consultant should review the profile and confirm the next step."
)
How is generation constrained?
- The function receives the requirement summary and engine-selected solution as separate arguments.
- The prompt builds its context from retrieved service descriptions and official source URLs.
- The instructions prohibit unsupported services, prices, guaranteed savings, certifications, timelines, and capabilities.
- The fallback preserves the deterministic selection when Ollama cannot be reached.
- Replace app/main.py by using the full-file reference in the second tab below:
✔️ Awesome, I've got everything!
Save app/main.py after checking that the chat route now calls the qualification engine before Ollama.
ⓧ I'd like to double check the full code
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from app.ai.assistant import MODEL, create_grounded_recommendation
from app.ai.knowledge import SERVICE_KNOWLEDGE
from app.ai.retriever import KeywordRetriever, Retriever
from app.sales_qualification import SalesQualificationEngine
FRONTEND_DIR = Path(__file__).resolve().parent / "frontend"
class ChatRequest(BaseModel):
stage: int
message: str
answers: dict[str, str]
class Source(BaseModel):
title: str
url: str
class ChatResponse(BaseModel):
stage: int
assistant_message: str
complete: bool
answers: dict[str, str]
requirement_summary: str
appropriate_solution: str
qualification_status: str
transfer_status: str
sources: list[Source]
app = FastAPI(title="MTS AI Sales Qualification Assistant", version="1.0.0")
retriever: Retriever = KeywordRetriever(SERVICE_KNOWLEDGE)
qualification_engine = SalesQualificationEngine(retriever)
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok", "model": MODEL}
@app.post("/api/chat", response_model=ChatResponse)
def chat(request: ChatRequest) -> ChatResponse:
stage = max(0, min(request.stage, qualification_engine.total_stages - 1))
answers = qualification_engine.record_answer(stage, request.message, request.answers)
if stage < qualification_engine.total_stages - 1:
next_stage = stage + 1
return ChatResponse(
stage=next_stage,
assistant_message=qualification_engine.question_for_stage(next_stage, answers),
complete=False,
answers=answers,
requirement_summary="",
appropriate_solution="",
qualification_status="Collecting qualification details",
transfer_status="Not evaluated",
sources=[],
)
result = qualification_engine.evaluate(answers)
recommendation = create_grounded_recommendation(
result.requirement_summary,
result.appropriate_solution,
result.documents,
)
sources = [
Source(title=document.name, url=document.source_url)
for document in result.documents
]
return ChatResponse(
stage=qualification_engine.total_stages,
assistant_message=recommendation,
complete=True,
answers=answers,
requirement_summary=result.requirement_summary,
appropriate_solution=result.appropriate_solution,
qualification_status=result.status,
transfer_status="Not evaluated",
sources=sources,
)
@app.get("/", response_class=FileResponse)
def index() -> FileResponse:
return FileResponse(str(FRONTEND_DIR / "index.html"))
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
How does the API expose the decision?
- The shared KeywordRetriever receives the curated service catalog when the application starts.
- Incomplete profiles receive the next question from question_for_stage().
- Complete profiles receive one QualificationResult before the model is called.
- The response returns the requirement, selected solution, qualification status, and source records to the browser.
- Save app/ai/assistant.py.
- Save app/main.py.
- Switch back to the FastAPI terminal.
- Start the development server if it is no longer running by using this command:
fastapi dev
What does this command do?
The command starts the development server with automatic reload support. It serves the updated API and existing browser interface from the project.
- Confirm the server finishes loading without a traceback.
- Return to http://127.0.0.1:8000 in your browser.
- Refresh the page to load a new qualification session.
Does the updated API fail to start?
- Check that create_grounded_recommendation has the same spelling in assistant.py and main.py.
- Check that SERVICE_KNOWLEDGE is imported from app.ai.knowledge.
- Check that KeywordRetriever receives SERVICE_KNOWLEDGE before the qualification engine is created.
Ask for help: Help me debug the FastAPI integration for my retriever, qualification engine, and grounded recommendation function.
Before you test the first scenario, which follow-up question do you expect after an industrial energy-efficiency challenge?
- Click Reset if the browser still shows your previous completed profile.
- Complete the first four contact fields with safe test details.
- Describe an industrial energy-efficiency challenge involving boilers and motors.
You should see the systems assessment question. It asks about boilers, turbines, HVAC, motors, transformers, pumps, compressed air, power quality, or the wider facility.
- Answer the systems question with the equipment that needs assessment.
- Enter decision maker for the role.
- Enter 0-3 months for the timeline.
- Enter approved for the budget readiness.
The result should show Energy Audit as the appropriate MTS solution. The page should also display the identified requirement, grounded explanation, qualification status, and official MTS source link.
What did this scenario prove?
The challenge selected the Energy Audit record before the requirement-detail stage. The completed profile retrieved again with extra context before the final decision.
The browser displays the engine's decision above the generated explanation. This separates deterministic qualification from generated prose.
Before you test a different challenge, do you expect the follow-up question and selected solution to stay the same?
- Click Reset to start a second profile.
- Complete the first four contact fields with safe test details.
- Describe a Scope 1, Scope 2, and Scope 3 emissions accounting challenge.
You should see the GHG-specific question. It asks about accounting scope, carbon-footprint reporting, data management, or climate planning.
- Answer the GHG-specific requirement question.
- Enter evaluator for the role.
- Enter 3-6 months for the timeline.
- Enter planning for the budget readiness.
You should now see GHG Accounting as the appropriate MTS solution. Its follow-up question and final decision should differ from the Energy Audit scenario.
That is the grounding boundary working: verified records choose the question and solution. Ollama explains the selected match without becoming the source of the decision.
Seeing the generic question or an empty decision?
- Refresh the browser after the FastAPI server finishes reloading.
- Check that the challenge contains terms represented in the service record's name, description, or keywords.
- Check that question_for_stage() receives the stored challenge before the requirement-detail stage.
Ask for help: Help me trace why my adaptive question or selected solution is empty in the browser.
Your assistant now asks service-specific questions and selects verified MTS solutions before generation. Next, you will route qualified profiles into Google Sheets and package the complete application for Docker.
Transfer Qualified Leads and Containerize the App
Your qualification engine now identifies a verified MTS solution before Ollama explains the decision. The result becomes useful to sales when qualified profiles arrive as consistent records that the team can act on.
In this step, you will protect a Google Sheets handoff with a shared secret. You will connect that handoff to FastAPI. You will then package the complete app with Docker while the local model stays on the Windows host.
In this step, get ready to:
- Create a protected Google Sheets receiver for qualified profiles.
- Connect the qualification result to the server-side sales handoff.
- Build the Docker image and demonstrate the complete containerized flow.
Create the protected Google Sheets receiver
The shared sheet gives sales one destination for qualified profiles. A bound Google Apps Script web app receives each protected backend request before appending a row.
Why a server-side handoff?
The browser never receives the shared secret. The FastAPI backend submits the prospect record after the deterministic qualification gate approves it.
This boundary keeps low-intent profiles out of the sales sheet. It also keeps the handoff decision independent from generated model prose.
- Create a new Google Sheet using the Google account prepared for this project.
- Enter these headers across the first row: received time, prospect ID, contact name, company, work email, phone, challenge, identified requirement, appropriate solution, role, timeline, budget, status, recommendation, matched services.
- Restrict the sheet through its sharing controls so only the intended sales team can access prospect information.
- Select Extensions from the Google Sheets menu.
- Select Apps Script to create the bound project.
The shared secret protects the receiver from unapproved submissions. Store the long value in a password manager because it must match the backend configuration exactly.
Protect the shared secret
The next edit handles a credential. Keep the secret out of screenshots and source-control commits.
The code reference uses the safe placeholder your-shared-secret-here. Replace it only inside your private Apps Script project.
- Replace the default contents of Code.gs with the complete receiver in the double-check tab below.
- Replace your-shared-secret-here with a long secret stored in your password manager.
- Save Code.gs.
✔️ Awesome, I've got everything!
Your receiver should validate the secret before appending any prospect fields to the active sheet.
ⓧ I'd like to double check the full code
Compare your receiver with this complete file. Keep your own secret in place when you finish the comparison.
const SHARED_SECRET = "your-shared-secret-here";
function jsonResponse(payload) {
return ContentService.createTextOutput(JSON.stringify(payload))
.setMimeType(ContentService.MimeType.JSON);
}
function safeCell(value) {
const text = value === undefined || value === null ? "" : String(value);
return text.startsWith("=") ? `'${text}` : text;
}
function doPost(e) {
try {
const payload = JSON.parse(e.postData.contents);
if (payload.secret !== SHARED_SECRET) {
return jsonResponse({ok: false, error: "unauthorized"});
}
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0];
sheet.appendRow([
new Date(),
safeCell(payload.prospect_id),
safeCell(payload.contact_name),
safeCell(payload.company),
safeCell(payload.work_email),
safeCell(payload.phone),
safeCell(payload.challenge),
safeCell(payload.requirement_summary),
safeCell(payload.appropriate_solution),
safeCell(payload.role),
safeCell(payload.timeline),
safeCell(payload.budget),
safeCell(payload.qualification_status),
safeCell(payload.recommendation),
safeCell((payload.matched_services || []).join(", ")),
]);
return jsonResponse({ok: true, prospect_id: payload.prospect_id});
} catch (error) {
return jsonResponse({ok: false, error: String(error)});
}
}
What does the receiver do?
- The doPost() handler reads the incoming JSON body before checking its secret.
- The safeCell() helper prefixes values that begin with =. This prevents prospect text from becoming a sheet formula.
- The row preserves the engine's requirement summary and selected solution alongside the contact profile.
- The JSON response tells FastAPI whether the append succeeded.
- Click Deploy in the Apps Script editor.
- Select New deployment.
- Choose Web app as the deployment type.
- Configure the web app to run as the deploying user.
- Permit users who are not logged in only if your organizational policy allows it.
- Complete any Google authorization review requested for the deployment.
- Click Deploy to create the production deployment.
- Copy the production URL that ends in /exec.
Missing the anonymous access option?
Your Google Workspace policy may restrict access for users who are not logged in. Keep that policy intact and ask your administrator about an approved authenticated integration.
Ask for help: My Google Apps Script deployment does not allow users who are not logged in. Help me explain the policy limitation and identify an approved authenticated handoff option.
Connect qualified results to the handoff
The handoff module keeps transport logic outside the qualification engine. It reads configuration from the server environment and creates a unique prospect ID for every approved transfer.
- Create app/sales_handoff.py in the existing app folder.
- Add the imports and configuration check by copying this first chunk:
import json
import os
import uuid
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
WEB_APP_URL_VARIABLE = "GOOGLE_SHEETS_WEB_APP_URL"
SHARED_SECRET_VARIABLE = "GOOGLE_SHEETS_SHARED_SECRET"
def transfer_qualified_lead(
answers: dict[str, str],
requirement_summary: str,
appropriate_solution: str,
recommendation: str,
matched_services: list[str],
) -> str:
web_app_url = os.getenv(WEB_APP_URL_VARIABLE, "")
shared_secret = os.getenv(SHARED_SECRET_VARIABLE, "")
if not web_app_url or not shared_secret:
return "Sales Sheet is not configured"
What does this code do?
- The two constants hold the environment-variable names expected by the backend.
- The function receives the deterministic decision fields and matched service names.
- The early return provides a clear status when either configuration value is missing.
- Continue transfer_qualified_lead() directly below the configuration check by copying this chunk:
payload = {
"secret": shared_secret,
"prospect_id": str(uuid.uuid4()),
"contact_name": answers.get("contact_name", ""),
"company": answers.get("company", ""),
"work_email": answers.get("work_email", ""),
"phone": answers.get("phone", ""),
"challenge": answers.get("challenge", ""),
"requirement_summary": requirement_summary,
"appropriate_solution": appropriate_solution,
"role": answers.get("role", ""),
"timeline": answers.get("timeline", ""),
"budget": answers.get("budget", ""),
"qualification_status": "Qualified for sales follow-up",
"recommendation": recommendation,
"matched_services": matched_services,
}
request = Request(
web_app_url,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
How is the lead packaged?
- The payload combines the contact profile with the requirement and selected solution.
- A random UUID becomes the prospect ID for tracing the row.
- The request sends JSON from the backend to the protected web-app URL.
- Save app/sales_handoff.py.
- Confirm the file remains visible under the app folder in the VS Code Explorer sidebar.
- Finish transfer_qualified_lead() by copying this final chunk below the request:
try:
with urlopen(request, timeout=15) as response:
result = json.loads(response.read().decode("utf-8"))
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError):
return "Sales Sheet transfer failed"
return (
"Sent to sales Google Sheet"
if result.get("ok") is True
else "Sales Sheet rejected the transfer"
)
How is the transfer result reported?
Network and response failures return a stable failure status to the browser. A successful JSON response becomes Sent to sales Google Sheet.
A response without ok set to true becomes a rejection status. The frontend can report every outcome without exposing the payload.
- Save app/sales_handoff.py.
- Return to app/main.py from the previous step.
- Add this import below the existing retrieval imports:
from app.sales_handoff import transfer_qualified_lead
Why import the handoff here?
The API route owns the final routing decision. It can call the handoff only after the qualification engine returns the approved status.
- Find the sources list inside chat().
- Add this qualification branch immediately below that list:
if result.status == "Qualified for sales follow-up":
transfer_status = transfer_qualified_lead(
answers,
result.requirement_summary,
result.appropriate_solution,
recommendation,
[document.name for document in result.documents],
)
else:
transfer_status = "Not sent because prospect needs further qualification"
What does the gate enforce?
Only the exact qualified status invokes transfer_qualified_lead(). Every other result receives a local non-transfer message.
The sheet therefore reflects the deterministic role, timeline, budget, and service-match gate. Model-generated wording cannot bypass it.
- Save app/main.py.
- Return to the PowerShell terminal with the activated virtual environment.
- Set the production web-app URL for the current PowerShell session by replacing the URL placeholder in this command:
$Env:GOOGLE_SHEETS_WEB_APP_URL = "your-google-apps-script-web-app-url-here"
What does this setting do?
The environment variable gives the backend its Apps Script destination. The value applies to this PowerShell process and child processes started from it.
- Set the matching shared secret for the current PowerShell session by replacing the plain placeholder in this command:
$Env:GOOGLE_SHEETS_SHARED_SECRET = "your-shared-secret-here"
Why must the secrets match?
Apps Script compares the submitted value with SHARED_SECRET before appending a row. Any mismatch produces a rejected transfer.
Keep this PowerShell command out of screenshots because it contains the live credential.
- Start the updated local app by running:
fastapi dev
You should see the development server start at http://127.0.0.1:8000.
Does the local handoff fail?
- Confirm the deployment URL ends in /exec.
- Confirm the secret in PowerShell matches the private value stored in Code.gs.
- Restart the FastAPI process from the PowerShell session where both environment variables are set.
Ask for help: My qualified profile returns a failed or rejected Google Sheets transfer. Help me compare the deployment URL, PowerShell environment settings, Apps Script secret check, and deployment access policy without exposing my secret.
Before you test the gate, which profile do you expect to create a row: an approved decision maker with a near-term timeline or a researcher who is still exploring?
- Open http://127.0.0.1:8000 in your browser.
- Complete a matching profile with fictional contact details.
- Choose decision maker as the role.
- Choose 0-3 months as the timeline.
- Choose approved as the budget readiness.
You should see Qualified for sales follow-up followed by Sent to sales Google Sheet. The sheet should contain a new row with the same requirement and selected solution.
- Reset the browser conversation.
- Complete another matching profile with fictional contact details.
- Choose researcher as the role.
- Choose 6+ months as the timeline.
- Choose exploring as the budget readiness.
You should see Needs further qualification followed by Not sent because prospect needs further qualification. The sheet row count should remain unchanged after this second profile.
Package and demonstrate the complete flow
The application now has a complete browser-to-sheet workflow. The container packages the frontend and backend while host.docker.internal lets it reach the Ollama service running on Windows.
Why keep Ollama on Windows?
Docker packages the application without adding model volumes or a second model runtime. The container uses Docker Desktop's host address to call the existing local Ollama API.
This keeps the deployment focused on the application boundary. The model stays available to both local and containerized runs.
- Create Dockerfile beside requirements.txt in the existing project folder.
- Add this container definition:
FROM python:3.14
WORKDIR /code
COPY ./requirements.txt /code/requirements.txt
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
COPY ./app /code/app
CMD ["fastapi", "run", "app/main.py", "--port", "80"]
What does the Dockerfile do?
- The image starts from the official Python 3.14 base.
- The dependency layer installs the pinned packages from requirements.txt.
- The app layer copies the backend and frontend into /code/app.
- The final command starts FastAPI on container port 80.
- Save Dockerfile.
- Build the application image as myimage by running:
docker build -t myimage .
You should see the build complete without an error. Docker now has a local image named myimage.
Does the image build fail?
- Confirm Dockerfile is beside requirements.txt.
- Confirm Docker Desktop is still running.
- Confirm the app folder contains the backend and frontend files.
Ask for help: My myimage Docker build fails. Help me inspect the Dockerfile location, requirements file, app folder, and Docker Desktop status from the error output.
Before you start the container, do you expect the app to reach Ollama through the container's own localhost address or through the Docker Desktop host address?
- Start mycontainer with the handoff environment variables by running:
docker run -d --name mycontainer -p 80:80 --env GOOGLE_SHEETS_WEB_APP_URL --env GOOGLE_SHEETS_SHARED_SECRET myimage
Docker should return the new container ID. The app receives both values from the current PowerShell session and reaches Ollama through host.docker.internal.
Does the container fail to start?
- Confirm Docker Desktop is running before retrying the command.
- Confirm the current PowerShell session still contains both handoff environment variables.
- Confirm no existing container already uses the name mycontainer.
Ask for help: My mycontainer Docker run command fails. Help me interpret the error and check Docker Desktop, port 80, the container name, and the two inherited environment variables without exposing their values.
- Open http://127.0.0.1 in your browser.
- Complete an industrial energy-efficiency profile with fictional contact details.
- Choose decision maker as the role.
- Choose 0-3 months as the timeline.
- Choose approved as the budget readiness.
You should receive the Energy Audit systems follow-up. The finished result should display Energy Audit with a grounded explanation and an official MTS source link.
You should also see Sent to sales Google Sheet. The new sheet row should contain the same requirement summary and appropriate solution shown in the browser.
✔️ Awesome, I've got everything!
Great work. Your qualified-only handoff now runs from the container while Ollama remains on the Windows host.
ⓧ I'd like to double check the full code
Compare the three project files changed in this step. Keep your private Apps Script secret in place.
import json
import os
import uuid
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
WEB_APP_URL_VARIABLE = "GOOGLE_SHEETS_WEB_APP_URL"
SHARED_SECRET_VARIABLE = "GOOGLE_SHEETS_SHARED_SECRET"
def transfer_qualified_lead(
answers: dict[str, str],
requirement_summary: str,
appropriate_solution: str,
recommendation: str,
matched_services: list[str],
) -> str:
web_app_url = os.getenv(WEB_APP_URL_VARIABLE, "")
shared_secret = os.getenv(SHARED_SECRET_VARIABLE, "")
if not web_app_url or not shared_secret:
return "Sales Sheet is not configured"
payload = {
"secret": shared_secret,
"prospect_id": str(uuid.uuid4()),
"contact_name": answers.get("contact_name", ""),
"company": answers.get("company", ""),
"work_email": answers.get("work_email", ""),
"phone": answers.get("phone", ""),
"challenge": answers.get("challenge", ""),
"requirement_summary": requirement_summary,
"appropriate_solution": appropriate_solution,
"role": answers.get("role", ""),
"timeline": answers.get("timeline", ""),
"budget": answers.get("budget", ""),
"qualification_status": "Qualified for sales follow-up",
"recommendation": recommendation,
"matched_services": matched_services,
}
request = Request(
web_app_url,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urlopen(request, timeout=15) as response:
result = json.loads(response.read().decode("utf-8"))
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError):
return "Sales Sheet transfer failed"
return (
"Sent to sales Google Sheet"
if result.get("ok") is True
else "Sales Sheet rejected the transfer"
)
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from app.ai.assistant import MODEL, create_grounded_recommendation
from app.ai.knowledge import SERVICE_KNOWLEDGE
from app.ai.retriever import KeywordRetriever, Retriever
from app.sales_handoff import transfer_qualified_lead
from app.sales_qualification import SalesQualificationEngine
FRONTEND_DIR = Path(__file__).resolve().parent / "frontend"
class ChatRequest(BaseModel):
stage: int
message: str
answers: dict[str, str]
class Source(BaseModel):
title: str
url: str
class ChatResponse(BaseModel):
stage: int
assistant_message: str
complete: bool
answers: dict[str, str]
requirement_summary: str
appropriate_solution: str
qualification_status: str
transfer_status: str
sources: list[Source]
app = FastAPI(title="MTS AI Sales Qualification Assistant", version="1.0.0")
retriever: Retriever = KeywordRetriever(SERVICE_KNOWLEDGE)
qualification_engine = SalesQualificationEngine(retriever)
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok", "model": MODEL}
@app.post("/api/chat", response_model=ChatResponse)
def chat(request: ChatRequest) -> ChatResponse:
stage = max(0, min(request.stage, qualification_engine.total_stages - 1))
answers = qualification_engine.record_answer(stage, request.message, request.answers)
if stage < qualification_engine.total_stages - 1:
next_stage = stage + 1
return ChatResponse(
stage=next_stage,
assistant_message=qualification_engine.question_for_stage(next_stage, answers),
complete=False,
answers=answers,
requirement_summary="",
appropriate_solution="",
qualification_status="Collecting qualification details",
transfer_status="Not evaluated",
sources=[],
)
result = qualification_engine.evaluate(answers)
recommendation = create_grounded_recommendation(
result.requirement_summary,
result.appropriate_solution,
result.documents,
)
sources = [
Source(title=document.name, url=document.source_url)
for document in result.documents
]
if result.status == "Qualified for sales follow-up":
transfer_status = transfer_qualified_lead(
answers,
result.requirement_summary,
result.appropriate_solution,
recommendation,
[document.name for document in result.documents],
)
else:
transfer_status = "Not sent because prospect needs further qualification"
return ChatResponse(
stage=qualification_engine.total_stages,
assistant_message=recommendation,
complete=True,
answers=answers,
requirement_summary=result.requirement_summary,
appropriate_solution=result.appropriate_solution,
qualification_status=result.status,
transfer_status=transfer_status,
sources=sources,
)
@app.get("/", response_class=FileResponse)
def index() -> FileResponse:
return FileResponse(str(FRONTEND_DIR / "index.html"))
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
FROM python:3.14
WORKDIR /code
COPY ./requirements.txt /code/requirements.txt
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
COPY ./app /code/app
CMD ["fastapi", "run", "app/main.py", "--port", "80"]
Secret mission
Red-Team the Sales Guardrails
The assistant has passed normal demonstrations. Push it with three adversarial profiles that target its decision boundaries. Record every result in a red-team matrix.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep your resources available, pause the local app, or delete the project assets entirely. Personal-learning use has no ongoing cloud compute charge.
Cost warning
Docker Personal costs $0. Commercial use of Docker Desktop in an enterprise with more than 250 employees or more than $10 million USD in annual revenue requires a paid subscription.
- Confirm MTS licensing before using the application for business work.
- Confirm your Google Workspace policy before keeping the anonymous web app deployment active.
Resources you used:
- Docker container mycontainer using host port 80.
- Docker image myimage containing the FastAPI application.
- Local Ollama model gemma3:1b.
- Local project artifacts including .venv, app, Dockerfile, requirements.txt, pyproject.toml, and red-team-test-matrix.md.
- Restricted sales sheet in Google Sheets containing prospect records.
- Protected Google Apps Script web app deployment with a production /exec URL.
- Process-scoped PowerShell handoff settings for GOOGLE_SHEETS_WEB_APP_URL and GOOGLE_SHEETS_SHARED_SECRET.
Keep everything running
No action is needed. Choose this option if you want to continue testing the complete qualification flow.
- Keep mycontainer running for browser demonstrations.
- Keep the Ollama host service available for local recommendations.
- Retain myimage for future containers.
- Keep the Apps Script deployment active for qualified lead handoffs.
- Restrict the Google Sheet to the intended sales team.
- Retain red-team-test-matrix.md as evidence of the guardrail checks.
Pause - I'll come back to this later
Shut down the local application process while preserving the image, model, source files, sales sheet, and deployment.
- Stop mycontainer by running this command:
docker stop mycontainer
What does this command do?
Docker stops the application inside mycontainer. The myimage image remains available for another run.
Container not stopping?
- Return to Docker Desktop to confirm its engine is running.
- Retry the stop command after the engine becomes available.
Ask for help with a container that will not stop.
- Close the PowerShell session from earlier to clear its process-scoped handoff settings.
- Keep the restricted Google Sheet for your recorded test results.
- Keep the Apps Script deployment archived only if you do not want it accepting handoffs while the app is paused.
Delete - I don't want to use this again
Deleting these assets is permanent, so save any prospect-free screenshots or learning notes you want to keep first.
- Stop mycontainer before removing it by running this command:
docker stop mycontainer
What does this command do?
Docker stops the application process in mycontainer. The container remains stored until you remove it.
Container still running?
- Return to Docker Desktop to confirm its engine is available.
- Retry the command after Docker Desktop finishes starting.
Ask for help stopping the application container.
- Return to the container list in Docker Desktop.
- Remove mycontainer using its container controls.
You should no longer see mycontainer in the container list.
- Remove myimage by running this command:
docker image rm myimage
What does this command remove?
Docker deletes the built application image named myimage. This reclaims the local storage used by that image.
Image still in use?
- Confirm mycontainer no longer appears in the Docker Desktop container list.
- Retry the image removal command after removing the container.
Ask for help removing the local image.
- Remove the local gemma3:1b model by running this command:
ollama rm gemma3:1b
What does this command remove?
Ollama deletes the local gemma3:1b model file. The removal reclaims the storage used by the 806 MB model.
Model not removing?
- Confirm the Ollama host service is running in Windows.
- Retry the model removal command after Ollama becomes available.
Ask for help removing the local model.
Protect Prospect Data
The sales sheet can contain names, work emails, phone numbers, and business requirements. Delete test records before discarding the sheet.
- Return to the restricted Google Sheet from earlier.
- Open its bound Google Apps Script project.
- Archive the production web app deployment using the project deployment controls.
- Confirm the production deployment shows an archived state.
- Return to the restricted Google Sheet.
- Delete every test prospect row.
- Move the restricted Google Sheet to the trash in your Google account.
The archived deployment can no longer serve as the active handoff endpoint. The deleted test rows no longer remain in the sales sheet.
The local project artifacts are the final assets to remove. Use the existing VS Code project view so each exact file is easy to identify.
- Return to the Explorer sidebar in VS Code from earlier.
- Delete .venv.
- Delete app.
- Delete Dockerfile.
- Delete requirements.txt.
- Delete pyproject.toml.
- Delete red-team-test-matrix.md.
You should see the project tree clear of every artifact listed above.
- Close the PowerShell session from earlier to clear GOOGLE_SHEETS_WEB_APP_URL.
- Close any other PowerShell session holding GOOGLE_SHEETS_SHARED_SECRET.
The process-scoped handoff settings disappear when their PowerShell sessions close.
Nice Work!
Nice Work!
Outstanding work! Your browser-based MTS assistant now turns prospect answers into grounded qualification decisions before handing sales-ready leads to a protected shared sheet.
You've learned how to:
- Built an adaptive qualification experience using JavaScript backed by FastAPI.
- Created a deterministic, embedding-ready RAG engine that identifies the requirement before selecting a verified MTS solution for Ollama to explain.
- Routed only sales-ready profiles through a qualified-only handoff to Google Sheets from the Docker container.
- Secret Mission: Completed a three-case guardrail test matrix proving adaptive questioning, grounded service selection, claim restraint, qualification gating, and low-intent handoff blocking.
Ready to quiz yourself?