Build a Pokémon Prompt Router
Build a local Pokémon RAG assistant with routed specialist prompts.
Introduction
30 Second Summary
Pokémon questions can look similar while asking for very different kinds of help. Facts, comparisons, and battle advice each need their own focus.
In this project, you will build a terminal-based Kanto Field Guide that answers Pokémon questions with local retrieval-augmented generation (RAG). A single Ollama chat model performs both prompt selection and grounded answering over facts retrieved from Qdrant.
What You'll Build
You'll ask a question in the terminal, see the chosen specialist, inspect its source URLs, and receive an answer shaped for facts, comparisons, battle analysis, or off-topic requests.
By the end of this project, you'll have:
- A searchable Kanto field guide that answers questions about the first 50 Pokémon using locally retrieved evidence from PokéAPI.
- Visible specialist routing that labels each request as POKEDEX, COMPARE, BATTLE, or FALLBACK before showing the grounded answer.
- A repeatable route evaluation that checks four representative questions and shows PASS or CHECK beside every result.
- Secret Mission: Add a Team Coach specialist that reviews a small Pokémon lineup for type coverage, stat roles, and evidence gaps.
Are there any prerequisites?
You need basic Python familiarity plus Windows, Visual Studio Code, Python 3.10 or newer, a running local Qdrant server, Ollama, and qwen3.5:4b.
Setup requires internet access for package installation, model downloads, and one-time PokéAPI ingestion plus about 639 MB of free disk space for qwen3-embedding:0.6b.
Before We Start
Before any setup begins, this checkpoint turns your project goal into a clear commitment. You are building a local terminal-based Kanto Field Guide that retrieves PokéAPI-derived records from Qdrant and makes prompt routing visible while qwen3.5:4b remains the single chat model.
Set Up the Local Project
The Kanto Field Guide runs entirely on your computer. Python executes the project.
Ollama serves the local models. Qdrant stores the vectors that power retrieval.
This step creates an isolated workspace in Visual Studio Code. The checks ahead prove your local foundation is ready before you write any RAG code.
In this step, get ready to:
- Verify Python plus the local Ollama and Qdrant services.
- Create the project folder with an active virtual environment.
- Install the pinned Python dependencies plus the embedding model.
Verify the local tools
The Qdrant client requires Python 3.10 or newer. Ollama must also expose the chat model that answers every question.
- Press the Windows key to open Windows search.
- Type Windows PowerShell into the search field.
- Press Enter to open PowerShell.
- Check the Python version plus the available Ollama models by running:
python --version
ollama ls
What do these checks show?
- The python --version command reports the interpreter available to this project.
- The ollama ls command lists the models already available through Ollama.
✔️ I see Python 3.10 or higher
Good. Your Python installation can support the pinned Qdrant client.
- Continue with the local service checks below.
ⓧ I see an older Python version
The installed interpreter is too old for this project. A current stable Windows release gives the virtual environment a compatible Python version.
- Visit the official Python downloads page for Windows.
- Download a current stable Windows release.
- Complete the Python installation.
- Close the current PowerShell window.
- Open a new PowerShell window through Windows search.
- Confirm the upgraded version by running:
python --version
What confirms the upgrade?
The version output must report Python 3.10 or newer before you continue.
ⓧ Python is not found
Windows cannot currently locate a Python installation. Install a current stable release before creating the project environment.
- Visit the official Python downloads page for Windows.
- Download a current stable Windows release.
- Complete the Python installation.
- Close the current PowerShell window.
- Open a new PowerShell window through Windows search.
- Verify the installation by running:
python --version
What confirms the installation?
A Python version of 3.10 or newer confirms that PowerShell can locate the interpreter.
The Ollama output must include qwen3.5:4b. The embedding model can already appear here or be added later in this step.
- Open your web browser.
- Enter http://localhost:6333/dashboard in the address bar.
You should see the local Qdrant dashboard load. This confirms the vector database is reachable through its default local address.
Missing the chat model or dashboard?
- Start your local Ollama service if PowerShell could not list its models.
- Restore the required Ollama setup if qwen3.5:4b is missing from the list.
- Start your local Qdrant server if the dashboard does not load.
Still blocked? Help me diagnose my local Ollama or Qdrant setup.
Create the isolated project environment
A virtual environment keeps this project's packages separate from every other Python project. The pokemon-prompt-router folder gives those files one predictable home.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into the search field.
- Press Enter to open Visual Studio Code.
Why use a dedicated folder?
The dedicated folder keeps the virtual environment beside the source files that use it. Visual Studio Code can then discover the workspace interpreter automatically.
- Click File in the top menu bar.
- Select Open Folder.
- Choose your Desktop in the folder dialog.
- Click New folder in the folder dialog.
- Enter pokemon-prompt-router as the folder name.
- Press Enter to create the folder.
- Select the pokemon-prompt-router folder.
- Click Select Folder to open the project.
Visual Studio Code should now show pokemon-prompt-router in the Explorer sidebar. That confirms every file you create next lands in the correct folder.
- Click Terminal in the top menu bar.
- Select New Terminal.
The integrated terminal should open as PowerShell inside pokemon-prompt-router.
- Create the project virtual environment by running:
python -m venv .venv
What does this command do?
Python creates a .venv directory inside pokemon-prompt-router. That directory contains the isolated interpreter plus its package location.
- Activate the new environment by running:
.\.venv\Scripts\Activate.ps1
What does activation change?
Activation points Python package commands at the environment inside .venv. Your PowerShell prompt also begins with the active environment name.
✔️ The virtual environment is active
That setup hurdle is cleared. New packages now install into this project instead of your system Python.
ⓧ PowerShell blocks Activate.ps1
PowerShell can block local activation scripts through its execution policy. Python documents a user-scoped policy change for this situation.
- Apply the documented user-scoped policy by running:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
What does this policy change affect?
The command updates the execution policy for your Windows user account. It allows the virtual environment activation script to run.
- Approve the user-scoped policy change if PowerShell requests confirmation.
- Retry the activation by running:
.\.venv\Scripts\Activate.ps1
What confirms activation?
The PowerShell prompt begins with the active environment name when the script succeeds.
Still unable to activate the environment?
Confirm the .venv directory exists in the Explorer sidebar. Recreate it with the virtual environment command if the directory is missing.
Need another pair of eyes? Help me troubleshoot my PowerShell virtual environment activation.
Visual Studio Code also needs to use the interpreter inside .venv. Workspace-local environments are usually discovered automatically.
✔️ .venv is already selected
The Visual Studio Code status bar shows the workspace environment. Your editor and terminal now use the same Python installation.
ⓧ Another interpreter is selected
- Press Ctrl+Shift+P to open the Command Palette.
- Type Python: Select Interpreter into the Command Palette.
- Select the interpreter located inside .venv.
The Visual Studio Code status bar should now identify .venv as the selected environment.
Install the dependencies and embedding model
Pinned dependencies make the setup repeatable. The requirements.txt file records the exact client versions used by the remaining steps.
- Select the Explorer sidebar in Visual Studio Code.
- Click the New File button.
- Enter requirements.txt as the file name.
- Pin the project dependencies by pasting this content into requirements.txt:
ollama==0.6.3
qdrant-client==1.20.0
What are these dependencies?
- The ollama==0.6.3 package lets Python send embedding requests plus chat requests to the local Ollama service.
- The qdrant-client==1.20.0 package lets Python create the collection plus query its vectors.
- Save requirements.txt.
You should now see requirements.txt listed beside .venv in the Explorer sidebar.
✔️ Awesome, I've got everything!
Great. Your dependency file is saved with both required pins.
ⓧ I'd like to double check the full code
Compare your saved requirements.txt with this complete file:
ollama==0.6.3
qdrant-client==1.20.0
What should match?
The package names plus version pins must match exactly. Each dependency sits on its own line.
- Install the pinned dependencies into the active environment by running:
python -m pip install -r requirements.txt
What does this command do?
Python asks pip to read every entry in requirements.txt. The packages install into the active .venv environment.
The terminal should complete the installation without an error. That confirms both pinned clients are available to the project interpreter.
Seeing a dependency installation error?
- Confirm the PowerShell prompt begins with the active environment name.
- Check that requirements.txt contains the two exact lines shown above.
- Confirm your Python version is 3.10 or newer.
Still stuck? Help me troubleshoot the pinned package installation.
The embedding model converts Pokémon records plus questions into vectors. The download is about 639 MB.
This download can take several minutes on a slower connection. A moving progress indicator means Ollama is still working.
- Download the embedding model by running:
ollama pull qwen3-embedding:0.6b
What does this command add?
Ollama downloads qwen3-embedding:0.6b for local vector generation. The model stays on your computer for later ingestion plus retrieval.
The progress indicator should finish successfully. The model is then available without another download.
Embedding model download interrupted?
- Confirm the local Ollama service is running.
- Check that your internet connection remains available during the download.
- Retry the same pull command after the connection recovers.
Need help with the download? Help me troubleshoot the Ollama embedding model pull.
Before you run the final checks, which Python version plus model names do you expect the terminal to report?
- Verify the interpreter plus both local models by running:
python --version
ollama ls
What do the final checks prove?
- The Python result proves the active interpreter meets the project's minimum version.
- The Ollama list proves the chat model plus embedding model are available locally.
Python should report 3.10 or newer. Ollama should list qwen3.5:4b plus qwen3-embedding:0.6b.
- Switch back to the Qdrant dashboard from earlier.
- Refresh the dashboard page.
The dashboard should load again at http://localhost:6333/dashboard. That confirms Qdrant remains reachable after the project setup.
You have cleared the local setup hurdle. The project now has an isolated interpreter plus every service required for the RAG pipeline.
Your local foundation is ready. Next, you'll turn PokéAPI records into a searchable Kanto knowledge base.
Build the Kanto Knowledge Base
Your local tools are ready. The assistant now needs trustworthy Pokémon facts that it can retrieve before it generates an answer.
This step builds the first complete retrieval-augmented generation path. You will collect records from PokéAPI. You will index them in Qdrant. You will then use Ollama to produce a sourced baseline answer.
In this step, get ready to:
- Fetch the first 50 Pokémon plus the type records observed in that dataset.
- Embed the records with qwen3-embedding:0.6b and store them in the kanto_prompt_router collection.
- Retrieve six records and generate a sourced baseline answer with qwen3.5:4b.
Fetch and shape the source records
An ingestion script turns remote API responses into consistent text documents. Each document also carries metadata that identifies its record type plus its original source.
- Create ingest.py inside the open pokemon-prompt-router folder by using the new-file control in the VS Code file sidebar.
- Add the imports plus local service configuration by copying this code into ingest.py:
import json
from urllib.request import urlopen
from ollama import Client
from qdrant_client import QdrantClient, models
POKEMON_LIST_URL = "https://pokeapi.co/api/v2/pokemon?limit=50"
QDRANT_URL = "http://localhost:6333"
OLLAMA_URL = "http://localhost:11434"
COLLECTION_NAME = "kanto_prompt_router"
EMBEDDING_MODEL = "qwen3-embedding:0.6b"
STAT_ORDER = (
"hp",
"attack",
"defense",
"special-attack",
"special-defense",
"speed",
)
ollama_client = Client(host=OLLAMA_URL)
qdrant_client = QdrantClient(url=QDRANT_URL)
What does this code set up?
- The constants keep the remote dataset URL plus both local service addresses in one place.
- The STAT_ORDER tuple gives every Pokémon record the same stat layout.
- The two clients create reusable connections for embedding and vector storage.
- Save ingest.py.
- Confirm that Python can load the configuration by running this command in the PowerShell terminal:
python -c "import ingest; print(ingest.COLLECTION_NAME)"
You should see kanto_prompt_router. This proves the new module can import its installed clients.
Does the import fail?
- Check that ingest.py is inside the open pokemon-prompt-router folder.
- Check that the PowerShell prompt still shows the active .venv environment.
Still stuck? Help me diagnose why Python cannot import my ingest module or its installed clients.
- Add the JSON helpers below the existing client declarations in ingest.py:
def fetch_json(url: str) -> dict:
with urlopen(url, timeout=30) as response:
return json.load(response)
def display_name(value: str) -> str:
return value.replace("-", " ").title()
def resource_names(resources: list[dict]) -> str:
names = [display_name(resource["name"]) for resource in resources]
return ", ".join(names) if names else "none"
What do these helpers do?
- The fetch_json() helper downloads one JSON resource with a timeout.
- The display_name() helper turns API names such as special-attack into readable labels.
- The resource_names() helper formats relation lists for the final document text.
- Save ingest.py.
- Check the name formatter by running:
python -c "import ingest; print(ingest.display_name('special-attack'))"
You should see Special Attack in the terminal.
- Add the Pokémon document transformer below resource_names() in ingest.py:
def pokemon_document(data: dict, source_url: str) -> dict:
types = [entry["type"]["name"] for entry in sorted(data["types"], key=lambda item: item["slot"])]
abilities = [entry["ability"]["name"] for entry in data["abilities"] if entry["ability"]]
stat_values = {
entry["stat"]["name"]: entry["base_stat"]
for entry in data["stats"]
}
stats = ", ".join(
f"{display_name(stat)}: {stat_values.get(stat, 'unknown')}"
for stat in STAT_ORDER
)
text = (
f"Pokémon: {display_name(data['name'])}. "
f"Pokédex ID: {data['id']}. "
f"Types: {', '.join(display_name(value) for value in types)}. "
f"Possible abilities: {', '.join(display_name(value) for value in abilities)}. "
f"Base stats: {stats}. "
f"Height: {data['height']} decimetres. "
f"Weight: {data['weight']} hectograms."
)
return {
"kind": "pokemon",
"name": data["name"],
"text": text,
"source_url": source_url,
}
How is a Pokémon record shaped?
- The transformer sorts types by their API slot so dual-type records stay consistent.
- The generated text combines identity details with types plus abilities plus ordered base stats plus physical measurements.
- The returned payload preserves kind plus name plus text plus source_url for retrieval and citation.
- Add the type document transformer below pokemon_document() in ingest.py:
def type_document(data: dict, source_url: str) -> dict:
relations = data["damage_relations"]
type_name = display_name(data["name"])
text = (
f"Type matchup record for {type_name}. "
f"Deals double damage to: {resource_names(relations['double_damage_to'])}. "
f"Deals half damage to: {resource_names(relations['half_damage_to'])}. "
f"Deals no damage to: {resource_names(relations['no_damage_to'])}. "
f"Takes double damage from: {resource_names(relations['double_damage_from'])}. "
f"Takes half damage from: {resource_names(relations['half_damage_from'])}. "
f"Takes no damage from: {resource_names(relations['no_damage_from'])}."
)
return {
"kind": "type",
"name": data["name"],
"text": text,
"source_url": source_url,
}
How is a type record shaped?
Each type document captures all six damage-relation lists. These records give later battle questions evidence for offensive strengths plus defensive weaknesses plus resistances plus immunities.
- Save ingest.py.
- Test the Pokémon transformer against the first listed API record by running:
python -c "import ingest; listing=ingest.fetch_json(ingest.POKEMON_LIST_URL); url=listing['results'][0]['url']; print(ingest.pokemon_document(ingest.fetch_json(url), url)['kind'])"
You should see pokemon. That output proves the script can fetch a live record and turn it into the expected payload type.
Does the API check fail?
- Confirm that your computer has internet access because ingestion reads PokéAPI during this one-time build.
- Check that both transformer functions sit at the left indentation level in ingest.py.
Need help? Help me troubleshoot the PokéAPI fetch or Pokémon document transformation.
- Add the dataset builder below type_document() in ingest.py:
def build_documents() -> list[dict]:
listing = fetch_json(POKEMON_LIST_URL)
documents = []
type_urls = set()
for resource in listing["results"]:
data = fetch_json(resource["url"])
documents.append(pokemon_document(data, resource["url"]))
type_urls.update(entry["type"]["url"] for entry in data["types"])
for type_url in sorted(type_urls):
data = fetch_json(type_url)
documents.append(type_document(data, type_url))
return documents
Why collect type URLs in a set?
Several Pokémon share the same type. The set keeps each observed type URL once so the script creates one matchup document per type.
The finished list contains the first 50 Pokémon records followed by the distinct type records they reference. Every remote resource is materialized locally for later searches.
Fetching these records can take a short while because the script makes one request per Pokémon. A quiet terminal during the request loop does not indicate a failure.
- Save ingest.py.
- Confirm that both document kinds are produced by running:
python -c "import ingest; documents=ingest.build_documents(); print(len(documents), documents[0]['kind'], documents[-1]['kind'])"
You should see a document count followed by pokemon and type. The source data is now ready for embedding.
Embed and index the knowledge base
An embedding turns each text document into a vector. Similar questions and records then sit close together in vector space.
Why embed in one batch?
The script sends the complete text list to the embedding model in one call. It then reads the returned vector size before creating the collection, which keeps the Qdrant configuration aligned with the model output.
- Start the main() function below build_documents() by copying this first part:
def main() -> None:
documents = build_documents()
texts = [document["text"] for document in documents]
embeddings = ollama_client.embed(
model=EMBEDDING_MODEL,
input=texts,
).embeddings
if not embeddings:
raise RuntimeError("Ollama returned no embeddings.")
if qdrant_client.collection_exists(COLLECTION_NAME):
qdrant_client.delete_collection(collection_name=COLLECTION_NAME)
qdrant_client.create_collection(
collection_name=COLLECTION_NAME,
vectors_config=models.VectorParams(
size=len(embeddings[0]),
distance=models.Distance.COSINE,
),
)
What does the first half do?
- The embedding call converts all document text with qwen3-embedding:0.6b.
- The empty-result check stops ingestion before it creates an unusable collection.
- The collection logic replaces only kanto_prompt_router and configures cosine distance with the returned vector size.
This is the first half of one function. The next block continues at the same indentation level and completes it before you run the file.
- Complete main() by adding this code directly after the collection creation block:
points = [
models.PointStruct(
id=index,
vector=list(vector),
payload=document,
)
for index, (document, vector) in enumerate(
zip(documents, embeddings),
start=1,
)
]
qdrant_client.upsert(
collection_name=COLLECTION_NAME,
points=points,
wait=True,
)
print(f"Indexed {len(points)} documents into '{COLLECTION_NAME}'.")
if __name__ == "__main__":
main()
What does the second half do?
- Each PointStruct pairs one numbered vector with its complete document payload.
- The upsert waits for Qdrant to apply the points before the script reports success.
- The final guard runs main() when you execute ingest.py directly.
Before you run the ingestion, do you expect the collection to contain only Pokémon records or both Pokémon and type records?
- Save ingest.py.
- Build the local knowledge base by running:
python ingest.py
The embedding model may take a moment to process the batch. The first complete run also waits for every PokéAPI request before it prints the result.
You should see a line beginning with Indexed and ending with 'kanto_prompt_router'. The collection now contains both Pokémon records and observed type records.
Did ingestion stop early?
- Confirm that the local Ollama service still has qwen3-embedding:0.6b available.
- Confirm that the local Qdrant dashboard remains reachable at http://localhost:6333/dashboard.
- Check your internet connection if the traceback points to a PokéAPI request.
Need a hand? Help me debug why ingest.py cannot embed or upsert my Pokémon records.
✔️ Awesome, I've got everything!
Your saved ingest.py now builds the complete local collection and reports the indexed document count.
ⓧ I'd like to double check the full code
import json
from urllib.request import urlopen
from ollama import Client
from qdrant_client import QdrantClient, models
POKEMON_LIST_URL = "https://pokeapi.co/api/v2/pokemon?limit=50"
QDRANT_URL = "http://localhost:6333"
OLLAMA_URL = "http://localhost:11434"
COLLECTION_NAME = "kanto_prompt_router"
EMBEDDING_MODEL = "qwen3-embedding:0.6b"
STAT_ORDER = (
"hp",
"attack",
"defense",
"special-attack",
"special-defense",
"speed",
)
ollama_client = Client(host=OLLAMA_URL)
qdrant_client = QdrantClient(url=QDRANT_URL)
def fetch_json(url: str) -> dict:
with urlopen(url, timeout=30) as response:
return json.load(response)
def display_name(value: str) -> str:
return value.replace("-", " ").title()
def resource_names(resources: list[dict]) -> str:
names = [display_name(resource["name"]) for resource in resources]
return ", ".join(names) if names else "none"
def pokemon_document(data: dict, source_url: str) -> dict:
types = [entry["type"]["name"] for entry in sorted(data["types"], key=lambda item: item["slot"])]
abilities = [entry["ability"]["name"] for entry in data["abilities"] if entry["ability"]]
stat_values = {
entry["stat"]["name"]: entry["base_stat"]
for entry in data["stats"]
}
stats = ", ".join(
f"{display_name(stat)}: {stat_values.get(stat, 'unknown')}"
for stat in STAT_ORDER
)
text = (
f"Pokémon: {display_name(data['name'])}. "
f"Pokédex ID: {data['id']}. "
f"Types: {', '.join(display_name(value) for value in types)}. "
f"Possible abilities: {', '.join(display_name(value) for value in abilities)}. "
f"Base stats: {stats}. "
f"Height: {data['height']} decimetres. "
f"Weight: {data['weight']} hectograms."
)
return {
"kind": "pokemon",
"name": data["name"],
"text": text,
"source_url": source_url,
}
def type_document(data: dict, source_url: str) -> dict:
relations = data["damage_relations"]
type_name = display_name(data["name"])
text = (
f"Type matchup record for {type_name}. "
f"Deals double damage to: {resource_names(relations['double_damage_to'])}. "
f"Deals half damage to: {resource_names(relations['half_damage_to'])}. "
f"Deals no damage to: {resource_names(relations['no_damage_to'])}. "
f"Takes double damage from: {resource_names(relations['double_damage_from'])}. "
f"Takes half damage from: {resource_names(relations['half_damage_from'])}. "
f"Takes no damage from: {resource_names(relations['no_damage_from'])}."
)
return {
"kind": "type",
"name": data["name"],
"text": text,
"source_url": source_url,
}
def build_documents() -> list[dict]:
listing = fetch_json(POKEMON_LIST_URL)
documents = []
type_urls = set()
for resource in listing["results"]:
data = fetch_json(resource["url"])
documents.append(pokemon_document(data, resource["url"]))
type_urls.update(entry["type"]["url"] for entry in data["types"])
for type_url in sorted(type_urls):
data = fetch_json(type_url)
documents.append(type_document(data, type_url))
return documents
def main() -> None:
documents = build_documents()
texts = [document["text"] for document in documents]
embeddings = ollama_client.embed(
model=EMBEDDING_MODEL,
input=texts,
).embeddings
if not embeddings:
raise RuntimeError("Ollama returned no embeddings.")
if qdrant_client.collection_exists(COLLECTION_NAME):
qdrant_client.delete_collection(collection_name=COLLECTION_NAME)
qdrant_client.create_collection(
collection_name=COLLECTION_NAME,
vectors_config=models.VectorParams(
size=len(embeddings[0]),
distance=models.Distance.COSINE,
),
)
points = [
models.PointStruct(
id=index,
vector=list(vector),
payload=document,
)
for index, (document, vector) in enumerate(
zip(documents, embeddings),
start=1,
)
]
qdrant_client.upsert(
collection_name=COLLECTION_NAME,
points=points,
wait=True,
)
print(f"Indexed {len(points)} documents into '{COLLECTION_NAME}'.")
if __name__ == "__main__":
main()
Build the grounded baseline app
Retrieval supplies evidence. A grounding prompt tells the chat model to stay inside that evidence and cite the numbered passages it uses.
- Create prompts.py inside the open pokemon-prompt-router folder by using the new-file control in the VS Code file sidebar.
- Add the baseline prompt plus route catalog by copying this code into prompts.py:
BASELINE_PROMPT = """
You are a grounded Pokémon field guide.
Answer only from the retrieved context.
Cite supporting passages with labels such as [1] and [2].
If the context does not contain the answer, say that the mini Pokédex does not contain enough evidence.
""".strip()
ROUTE_DESCRIPTIONS = {
"POKEDEX": (
"Direct facts about one Pokémon, including type, abilities, base stats, "
"height, weight, or identity."
),
"COMPARE": (
"A comparison between two or more Pokémon using their retrieved facts "
"and base stats."
),
"BATTLE": (
"Type matchups, strengths, weaknesses, resistances, immunities, or a "
"battle-oriented reading of retrieved stats."
),
"FALLBACK": (
"Requests unrelated to the indexed Pokémon field guide or requests "
"that do not fit another specialist job."
),
}
Why define the catalog now?
The baseline command uses only BASELINE_PROMPT in this step. The route descriptions establish a shared catalog that later coordinator logic can read without hard-coding intent descriptions throughout the app.
- Add the factual and comparison specialist prompts below ROUTE_DESCRIPTIONS in prompts.py:
SPECIALIST_PROMPTS = {
"POKEDEX": """
You are the Pokédex Researcher.
Give a compact factual profile for the Pokémon named in the question.
Use only retrieved context. Separate types, abilities, base stats, and physical measurements when available.
Cite every factual paragraph with source labels such as [1].
If the requested Pokémon or field is absent, state exactly what evidence is missing.
""".strip(),
"COMPARE": """
You are the Pokémon Comparison Analyst.
Compare only the Pokémon named by the user.
Use retrieved types and base stats to identify clear similarities, differences, and likely stat roles.
Do not invent moves, items, formats, or competitive rankings.
Use a side-by-side structure and cite each comparison point with source labels.
""".strip(),
What do these prompts control?
Each specialist prompt defines a narrow job plus evidence boundaries plus an answer structure. The model stays the same while its system instructions change.
This dictionary continues in the next block. Keep its closing brace for the end of that block.
- Complete SPECIALIST_PROMPTS with the battle and fallback entries:
"BATTLE": """
You are the Type and Battle Analyst.
Use only retrieved Pokémon records and type damage-relation records.
Explain offensive strengths, defensive weaknesses, resistances, immunities, and relevant base-stat tendencies.
Do not invent moves, abilities effects, held items, or game-format rules that are absent from context.
If dual-type interaction is discussed, show which retrieved type records support the conclusion.
Cite claims with source labels.
""".strip(),
"FALLBACK": """
You are the Field Guide Gatekeeper.
Explain briefly that this assistant is limited to the indexed Pokémon and type-matchup records.
Do not answer unrelated questions from general model knowledge.
If the request is Pokémon-related but the retrieved context is insufficient, name the missing evidence and suggest an in-scope question.
""".strip(),
}
How do the remaining prompts stay grounded?
The battle prompt limits analysis to retrieved Pokémon and type records. The fallback prompt prevents unrelated requests from being answered with general model knowledge.
- Save prompts.py.
- Confirm that the complete prompt catalog loads by running:
python -c "import prompts; print(', '.join(prompts.ROUTE_DESCRIPTIONS))"
You should see POKEDEX, COMPARE, BATTLE, FALLBACK in the terminal.
- Add the coordinator prompt builder below the specialist prompt dictionary in prompts.py:
def router_system_prompt() -> str:
catalog = "\n".join(
f"- {route}: {description}"
for route, description in ROUTE_DESCRIPTIONS.items()
)
return f"""
You are a coordinator for a Pokémon field guide.
Choose exactly one specialist route for the user's requested job.
Route by intent, not by whether the indexed dataset contains the requested Pokémon.
Available routes:
{catalog}
Examples:
- "What are Pikachu's base stats?" -> POKEDEX
- "Compare Charizard and Blastoise." -> COMPARE
- "Which types are strong against Charizard?" -> BATTLE
- "Write a SQL query." -> FALLBACK
Return only the structured route result.
""".strip()
What does the builder prepare?
The function converts the route dictionary into a coordinator-readable catalog. Its examples connect representative questions to the available labels.
- Save prompts.py.
✔️ Awesome, I've got everything!
Your saved prompts.py contains the grounded baseline prompt plus the complete specialist catalog.
ⓧ I'd like to double check the full code
BASELINE_PROMPT = """
You are a grounded Pokémon field guide.
Answer only from the retrieved context.
Cite supporting passages with labels such as [1] and [2].
If the context does not contain the answer, say that the mini Pokédex does not contain enough evidence.
""".strip()
ROUTE_DESCRIPTIONS = {
"POKEDEX": (
"Direct facts about one Pokémon, including type, abilities, base stats, "
"height, weight, or identity."
),
"COMPARE": (
"A comparison between two or more Pokémon using their retrieved facts "
"and base stats."
),
"BATTLE": (
"Type matchups, strengths, weaknesses, resistances, immunities, or a "
"battle-oriented reading of retrieved stats."
),
"FALLBACK": (
"Requests unrelated to the indexed Pokémon field guide or requests "
"that do not fit another specialist job."
),
}
SPECIALIST_PROMPTS = {
"POKEDEX": """
You are the Pokédex Researcher.
Give a compact factual profile for the Pokémon named in the question.
Use only retrieved context. Separate types, abilities, base stats, and physical measurements when available.
Cite every factual paragraph with source labels such as [1].
If the requested Pokémon or field is absent, state exactly what evidence is missing.
""".strip(),
"COMPARE": """
You are the Pokémon Comparison Analyst.
Compare only the Pokémon named by the user.
Use retrieved types and base stats to identify clear similarities, differences, and likely stat roles.
Do not invent moves, items, formats, or competitive rankings.
Use a side-by-side structure and cite each comparison point with source labels.
""".strip(),
"BATTLE": """
You are the Type and Battle Analyst.
Use only retrieved Pokémon records and type damage-relation records.
Explain offensive strengths, defensive weaknesses, resistances, immunities, and relevant base-stat tendencies.
Do not invent moves, abilities effects, held items, or game-format rules that are absent from context.
If dual-type interaction is discussed, show which retrieved type records support the conclusion.
Cite claims with source labels.
""".strip(),
"FALLBACK": """
You are the Field Guide Gatekeeper.
Explain briefly that this assistant is limited to the indexed Pokémon and type-matchup records.
Do not answer unrelated questions from general model knowledge.
If the request is Pokémon-related but the retrieved context is insufficient, name the missing evidence and suggest an in-scope question.
""".strip(),
}
def router_system_prompt() -> str:
catalog = "\n".join(
f"- {route}: {description}"
for route, description in ROUTE_DESCRIPTIONS.items()
)
return f"""
You are a coordinator for a Pokémon field guide.
Choose exactly one specialist route for the user's requested job.
Route by intent, not by whether the indexed dataset contains the requested Pokémon.
Available routes:
{catalog}
Examples:
- "What are Pikachu's base stats?" -> POKEDEX
- "Compare Charizard and Blastoise." -> COMPARE
- "Which types are strong against Charizard?" -> BATTLE
- "Write a SQL query." -> FALLBACK
Return only the structured route result.
""".strip()
The application now needs to connect retrieval with the prompt. Its baseline path always prints GENERIC so you can later compare that single prompt path with specialist routing.
- Create app.py inside the open pokemon-prompt-router folder by using the new-file control in the VS Code file sidebar.
- Add the imports plus service configuration by copying this code into app.py:
import argparse
import json
from ollama import Client
from qdrant_client import QdrantClient
from prompts import (
BASELINE_PROMPT,
ROUTE_DESCRIPTIONS,
SPECIALIST_PROMPTS,
router_system_prompt,
)
QDRANT_URL = "http://localhost:6333"
OLLAMA_URL = "http://localhost:11434"
COLLECTION_NAME = "kanto_prompt_router"
CHAT_MODEL = "qwen3.5:4b"
EMBEDDING_MODEL = "qwen3-embedding:0.6b"
RETRIEVAL_TASK = (
"Given a Pokémon question, retrieve relevant Pokédex facts and "
"type matchup passages that answer it"
)
ollama_client = Client(host=OLLAMA_URL)
qdrant_client = QdrantClient(url=QDRANT_URL)
What does the app configure?
- The chat model handles generated text while the embedding model handles retrieval vectors.
- The RETRIEVAL_TASK tells the embedding model what kind of passages the query needs.
- Both clients point to services already running on your computer.
- Add the structured route schema below the client declarations in app.py:
def route_schema() -> dict:
return {
"type": "object",
"properties": {
"route": {
"type": "string",
"enum": list(ROUTE_DESCRIPTIONS),
}
},
"required": ["route"],
"additionalProperties": False,
}
Why constrain the route value?
The schema limits a future coordinator response to route names already present in the catalog. This keeps model output compatible with the specialist prompt dictionary.
- Add the coordinator call below route_schema() in app.py:
def route_question(question: str) -> str:
response = ollama_client.chat(
model=CHAT_MODEL,
messages=[
{"role": "system", "content": router_system_prompt()},
{"role": "user", "content": question},
],
format=route_schema(),
options={"temperature": 0},
)
try:
route = json.loads(response.message.content or "{}").get("route", "FALLBACK")
except json.JSONDecodeError:
return "FALLBACK"
return route if route in ROUTE_DESCRIPTIONS else "FALLBACK"
How does the coordinator stay predictable?
The model receives the route catalog plus the question. Structured output plus a temperature of zero reduces variation.
Invalid JSON and unknown labels both fall back safely. The baseline command does not call this function yet.
- Save app.py.
- Confirm that the app configuration imports by running:
python -c "import app; print(app.COLLECTION_NAME)"
You should see kanto_prompt_router.
- Add the retrieval function below route_question() in app.py:
def retrieve(question: str, limit: int = 6):
query_text = f"Instruct: {RETRIEVAL_TASK}\nQuery:{question}"
query_vector = ollama_client.embed(
model=EMBEDDING_MODEL,
input=query_text,
).embeddings[0]
return qdrant_client.query_points(
collection_name=COLLECTION_NAME,
query=list(query_vector),
with_payload=True,
limit=limit,
).points
How does retrieval work?
The query uses the embedding model's instruction format to describe the retrieval task. Qdrant then returns the six closest vectors with their payloads.
- Add the context formatter below retrieve() in app.py:
def format_context(points) -> str:
sections = []
for index, point in enumerate(points, start=1):
payload = point.payload or {}
sections.append(
f"[{index}] {payload.get('text', 'Missing text')}\n"
f"Source: {payload.get('source_url', 'Missing source')}"
)
return "\n\n".join(sections)
Why number the retrieved passages?
The formatter labels each passage with a number and its original source URL. The prompt can then request citations such as [1] without losing the source behind that passage.
Before you test retrieval, do you expect the default query to return six points?
- Save app.py.
- Check the default retrieval limit by running:
python -c "import app; print(len(app.retrieve(\"What are Pikachu's type and base stats?\")))"
You should see 6 in the terminal. Your question is now reaching the indexed collection.
Does retrieval fail?
- Confirm that python ingest.py completed before this check.
- Confirm that Qdrant remains reachable at http://localhost:6333/dashboard.
- Confirm that Ollama still has the embedding model available.
Still blocked? Help me debug why app.retrieve cannot return six Qdrant points.
- Add the specialist answer function below format_context() in app.py:
def generate_answer(question: str, route: str, points) -> str:
prompt = SPECIALIST_PROMPTS.get(route, SPECIALIST_PROMPTS["FALLBACK"])
response = ollama_client.chat(
model=CHAT_MODEL,
messages=[
{"role": "system", "content": prompt},
{
"role": "user",
"content": (
f"Question:\n{question}\n\n"
f"Retrieved context:\n{format_context(points)}"
),
},
],
options={"temperature": 0.2},
)
return response.message.content or "The model returned no answer."
What prepares specialist answers?
This function selects a system prompt from the route label and sends the formatted evidence to the same chat model. The baseline path keeps it available without selecting it in this step.
- Add the baseline answer function below generate_answer() in app.py:
def generate_baseline_answer(question: str, points) -> str:
response = ollama_client.chat(
model=CHAT_MODEL,
messages=[
{"role": "system", "content": BASELINE_PROMPT},
{
"role": "user",
"content": (
f"Question:\n{question}\n\n"
f"Retrieved context:\n{format_context(points)}"
),
},
],
options={"temperature": 0.2},
)
return response.message.content or "The model returned no answer."
What makes this the baseline?
Every baseline question receives the same BASELINE_PROMPT. The model still gets retrieved evidence, but it receives no intent-specific role or answer structure.
- Save app.py.
- Confirm that the baseline prompt is available by running:
python -c "import app; print(app.BASELINE_PROMPT.splitlines()[0])"
You should see You are a grounded Pokémon field guide..
- Add the result printer below generate_baseline_answer() in app.py:
def print_result(question: str, baseline: bool = False) -> None:
if not qdrant_client.collection_exists(COLLECTION_NAME):
raise RuntimeError("Run 'python ingest.py' before asking questions.")
points = retrieve(question)
route = "GENERIC" if baseline else route_question(question)
answer = (
generate_baseline_answer(question, points)
if baseline
else generate_answer(question, route, points)
)
print(f"\n[Route] {route}")
print("[Sources]")
for index, point in enumerate(points, start=1):
payload = point.payload or {}
source = payload.get("source_url", "Missing source")
print(f" [{index}] {source} (score={point.score:.3f})")
print("\n[Answer]")
print(answer)
What becomes visible in the terminal?
The function prints the selected route first. It then prints every retrieved source URL with its similarity score before showing the answer.
Passing baseline=True forces the visible route to GENERIC and calls the generic grounding prompt.
- Add the interactive loop below print_result() in app.py:
def interactive_chat() -> None:
print("Kanto Prompt Router")
print("Type 'quit' to exit.")
while True:
question = input("\nYou: ").strip()
if question.lower() in {"quit", "exit"}:
break
if question:
print_result(question)
What does the loop provide?
The loop accepts repeated questions until the learner types a supported exit word. Each non-empty question travels through retrieval and answer generation.
- Save app.py.
- Run the baseline directly through the completed result function:
python -c "from app import print_result; print_result(\"What are Pikachu's type and base stats?\", baseline=True)"
You should see [Route] GENERIC plus six scored source URLs plus a grounded answer. That is the first complete local RAG result.
Does the model return no answer?
- Confirm that the Ollama service still has qwen3.5:4b available.
- Check that generate_baseline_answer() uses BASELINE_PROMPT and the formatted points.
Need help? Help me debug why my retrieved context does not produce a baseline answer.
- Add the command-line parser below interactive_chat() in app.py:
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Route Pokémon questions to grounded specialist prompts."
)
group = parser.add_mutually_exclusive_group()
group.add_argument("--baseline", metavar="QUESTION")
group.add_argument("--route", metavar="QUESTION")
group.add_argument("--ask", metavar="QUESTION")
return parser
What does the parser expose?
The parser gives the app three mutually exclusive command modes. This step uses the baseline mode while keeping the route and specialist modes ready for the later routing work.
- Add the entry point below build_parser() in app.py:
def main() -> None:
args = build_parser().parse_args()
if args.baseline:
print_result(args.baseline, baseline=True)
elif args.route:
print(route_question(args.route))
elif args.ask:
print_result(args.ask)
else:
interactive_chat()
if __name__ == "__main__":
main()
How does the entry point choose a mode?
The parsed argument determines which function runs. Starting the script without an argument enters the interactive loop.
Before you run the final check, what route label do you expect the baseline mode to print?
- Save app.py.
- Run the grounded baseline question with this command:
python app.py --baseline "What are Pikachu's type and base stats?"
You should see [Route] GENERIC followed by retrieved PokéAPI source URLs and a sourced answer about Pikachu. Your local knowledge base can now answer a question from retrieved evidence.
Is the final baseline failing?
- Confirm that the terminal still uses the active .venv environment.
- Confirm that ingest.py previously printed the indexed document count.
- Compare the argument names in build_parser() with the branches in main().
Still stuck? Help me debug the complete Pikachu baseline command.
✔️ Awesome, I've got everything!
Your saved app.py now retrieves six records and prints a grounded generic answer with visible sources.
ⓧ I'd like to double check the full code
import argparse
import json
from ollama import Client
from qdrant_client import QdrantClient
from prompts import (
BASELINE_PROMPT,
ROUTE_DESCRIPTIONS,
SPECIALIST_PROMPTS,
router_system_prompt,
)
QDRANT_URL = "http://localhost:6333"
OLLAMA_URL = "http://localhost:11434"
COLLECTION_NAME = "kanto_prompt_router"
CHAT_MODEL = "qwen3.5:4b"
EMBEDDING_MODEL = "qwen3-embedding:0.6b"
RETRIEVAL_TASK = (
"Given a Pokémon question, retrieve relevant Pokédex facts and "
"type matchup passages that answer it"
)
ollama_client = Client(host=OLLAMA_URL)
qdrant_client = QdrantClient(url=QDRANT_URL)
def route_schema() -> dict:
return {
"type": "object",
"properties": {
"route": {
"type": "string",
"enum": list(ROUTE_DESCRIPTIONS),
}
},
"required": ["route"],
"additionalProperties": False,
}
def route_question(question: str) -> str:
response = ollama_client.chat(
model=CHAT_MODEL,
messages=[
{"role": "system", "content": router_system_prompt()},
{"role": "user", "content": question},
],
format=route_schema(),
options={"temperature": 0},
)
try:
route = json.loads(response.message.content or "{}").get("route", "FALLBACK")
except json.JSONDecodeError:
return "FALLBACK"
return route if route in ROUTE_DESCRIPTIONS else "FALLBACK"
def retrieve(question: str, limit: int = 6):
query_text = f"Instruct: {RETRIEVAL_TASK}\nQuery:{question}"
query_vector = ollama_client.embed(
model=EMBEDDING_MODEL,
input=query_text,
).embeddings[0]
return qdrant_client.query_points(
collection_name=COLLECTION_NAME,
query=list(query_vector),
with_payload=True,
limit=limit,
).points
def format_context(points) -> str:
sections = []
for index, point in enumerate(points, start=1):
payload = point.payload or {}
sections.append(
f"[{index}] {payload.get('text', 'Missing text')}\n"
f"Source: {payload.get('source_url', 'Missing source')}"
)
return "\n\n".join(sections)
def generate_answer(question: str, route: str, points) -> str:
prompt = SPECIALIST_PROMPTS.get(route, SPECIALIST_PROMPTS["FALLBACK"])
response = ollama_client.chat(
model=CHAT_MODEL,
messages=[
{"role": "system", "content": prompt},
{
"role": "user",
"content": (
f"Question:\n{question}\n\n"
f"Retrieved context:\n{format_context(points)}"
),
},
],
options={"temperature": 0.2},
)
return response.message.content or "The model returned no answer."
def generate_baseline_answer(question: str, points) -> str:
response = ollama_client.chat(
model=CHAT_MODEL,
messages=[
{"role": "system", "content": BASELINE_PROMPT},
{
"role": "user",
"content": (
f"Question:\n{question}\n\n"
f"Retrieved context:\n{format_context(points)}"
),
},
],
options={"temperature": 0.2},
)
return response.message.content or "The model returned no answer."
def print_result(question: str, baseline: bool = False) -> None:
if not qdrant_client.collection_exists(COLLECTION_NAME):
raise RuntimeError("Run 'python ingest.py' before asking questions.")
points = retrieve(question)
route = "GENERIC" if baseline else route_question(question)
answer = (
generate_baseline_answer(question, points)
if baseline
else generate_answer(question, route, points)
)
print(f"\n[Route] {route}")
print("[Sources]")
for index, point in enumerate(points, start=1):
payload = point.payload or {}
source = payload.get("source_url", "Missing source")
print(f" [{index}] {source} (score={point.score:.3f})")
print("\n[Answer]")
print(answer)
def interactive_chat() -> None:
print("Kanto Prompt Router")
print("Type 'quit' to exit.")
while True:
question = input("\nYou: ").strip()
if question.lower() in {"quit", "exit"}:
break
if question:
print_result(question)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Route Pokémon questions to grounded specialist prompts."
)
group = parser.add_mutually_exclusive_group()
group.add_argument("--baseline", metavar="QUESTION")
group.add_argument("--route", metavar="QUESTION")
group.add_argument("--ask", metavar="QUESTION")
return parser
def main() -> None:
args = build_parser().parse_args()
if args.baseline:
print_result(args.baseline, baseline=True)
elif args.route:
print(route_question(args.route))
elif args.ask:
print_result(args.ask)
else:
interactive_chat()
if __name__ == "__main__":
main()
That is your first local Pokémon RAG answer working from ingestion to retrieval to generation. Next, you will test different question types and expose what one generic prompt cannot do well.
Expose the Generic-Prompt Limitation
Your local RAG app already retrieves Pokémon facts from Qdrant. It also returns a sourced baseline answer.
The baseline has one general instruction set for every request. In this step, you'll test a comparison job plus a battle job to see the limitation directly.
In this step, get ready to:
- Run a comparison question through the baseline prompt path.
- Run a battle question through the same baseline prompt path.
- Compare the route labels from both analytical jobs.
Test a comparison question
A comparison requires the model to line up facts for two Pokémon. This baseline run reveals whether your current path recognizes that job.
Before you run this, do you think the app can recognize comparison intent from its current prompt path?
- Run the baseline comparison from the PowerShell terminal in Visual Studio Code:
python app.py --baseline "Compare Charizard and Blastoise."
What Does This Check?
- The --baseline option sends the question through generate_baseline_answer().
- The BASELINE_PROMPT value supplies one grounding instruction set to qwen3.5:4b.
- The retrieve() function still fetches six matching records from Qdrant.
- Inspect the route label above the source list.
- Inspect the answer for a side-by-side comparison structure.
You'll see [Route] GENERIC above scored PokéAPI source URLs. The app retrieves evidence.
It applies no comparison-specific route. That shortfall is intentional.
Missing the Generic Route?
- Confirm PowerShell is still inside the pokemon-prompt-router folder.
- Confirm the terminal prompt still begins with (.venv).
- Return to the Qdrant dashboard from earlier to confirm kanto_prompt_router exists.
Still stuck? Help me troubleshoot why my baseline comparison does not print the generic route or retrieved sources.
Test a battle question
Battle analysis is a different specialist job. It needs type-matchup evidence to explain strengths against a Pokémon.
Before you run this, do you expect the route label to change for a type-matchup question?
- Run the baseline battle question from the same PowerShell terminal:
python app.py --baseline "Which types are strong against Charizard?"
What Does This Check?
- The --baseline option sends this battle question through the same baseline branch.
- The retrieval step can surface Charizard facts plus relevant type records.
- The answer step still receives BASELINE_PROMPT as its system prompt.
- Inspect the route label above the battle answer.
- Inspect the source list for retrieved URLs.
- Compare this route label with the comparison route label.
You'll again see [Route] GENERIC above retrieved source URLs. Both analytical jobs follow the same prompt path.
Battle Answer Not Appearing?
- Confirm the local Ollama service is still running.
- Return to the Qdrant dashboard from earlier to confirm the collection remains reachable.
- Check that the command includes the --baseline option.
Need another pair of eyes? Help me troubleshoot why my baseline battle question does not return a route, sources, or answer.
What Did These Runs Prove?
Retrieval works because both runs show scored source URLs. Prompt specialization is missing because both jobs print [Route] GENERIC.
You now have a visible before state for measuring the router.
Your baseline now has a visible limitation: two different jobs follow the same generic path. Next, you'll add specialist prompt routing.
The chat model remains qwen3.5:4b.
Route Questions to Specialists
Your baseline RAG pipeline already retrieves relevant records. However, every question still passes through one generic prompt.
In this step, the same Ollama chat model becomes a coordinator before it becomes an answerer. It first returns a constrained JSON route label. The app then selects the matching specialist system prompt.
In this step, get ready to:
- Confirm that each supported intent has a distinct route description and specialist prompt.
- Connect the command-line options to the existing coordinator functions.
- Run comparison and battle questions through their matching specialists.
Confirm the specialist prompt catalog
The coordinator needs a clear catalog of jobs. Your existing prompts.py already pairs each route description with a specialist prompt.
- Select prompts.py in the VS Code Explorer sidebar.
- Confirm that ROUTE_DESCRIPTIONS contains POKEDEX, COMPARE, BATTLE, and FALLBACK.
- Confirm that SPECIALIST_PROMPTS uses the same four route names.
- Read router_system_prompt() to identify how the route catalog becomes coordinator instructions.
You should see one route for direct facts. You should also see separate routes for comparisons, battle analysis, and unsupported requests.
Why Use One Model Twice?
The model tag stays qwen3.5:4b for both calls. The first call classifies the requested job.
The second call receives the selected specialist prompt plus the retrieved context. The prompt changes the role while the model stays the same.
Wire the command-line routes
The routing helpers already exist in app.py. The command-line parser now needs options that expose the coordinator without removing the baseline path.
- Switch back to app.py.
- Find the complete build_parser() function.
- Replace that function with this version:
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Route Pokémon questions to grounded specialist prompts."
)
group = parser.add_mutually_exclusive_group()
group.add_argument("--baseline", metavar="QUESTION")
group.add_argument("--route", metavar="QUESTION")
group.add_argument("--ask", metavar="QUESTION")
return parser
What Does This Code Do?
- The mutually exclusive group allows one operating mode per run.
- The --baseline option preserves the generic prompt for comparison.
- The --route option exposes only the coordinator decision.
- The --ask option requests routing followed by a grounded specialist answer.
- Save app.py.
- Confirm that the three arguments appear inside the same mutually exclusive group.
Missing a Command Option?
Check that all three group.add_argument lines stay inside build_parser(). Make sure each line uses the same group variable.
Still stuck? Help me check my command-line parser.
- Find the complete main() function near the bottom of app.py.
- Replace that function with this version:
def main() -> None:
args = build_parser().parse_args()
if args.baseline:
print_result(args.baseline, baseline=True)
elif args.route:
print(route_question(args.route))
elif args.ask:
print_result(args.ask)
else:
interactive_chat()
How Does the Mode Selection Work?
- The baseline branch keeps the original generic answer path available.
- The route branch prints the label returned by route_question().
- The ask branch passes the question to print_result() without enabling baseline mode.
- The final branch preserves the interactive chat experience.
- Save app.py.
Before you run the route check, which specialist do you expect the comparison question to select?
- Test the coordinator without generating an answer by running:
python app.py --route "Compare Charizard and Blastoise."
You should see COMPARE in the terminal. That proves the coordinator can classify the requested job through structured output.
Route Check Not Returning COMPARE?
Confirm that main() calls route_question(args.route) for the route branch. Check that the comparison example remains inside router_system_prompt().
Need another pair of eyes? Help me troubleshoot the route-only comparison check.
Dispatch answers through the selected specialist
The coordinator can now name the job. The answer path must use that label to select a specialist prompt while Qdrant continues to supply the supporting records.
- Find the complete print_result() function in app.py.
- Replace that function with this version:
def print_result(question: str, baseline: bool = False) -> None:
if not qdrant_client.collection_exists(COLLECTION_NAME):
raise RuntimeError("Run 'python ingest.py' before asking questions.")
points = retrieve(question)
route = "GENERIC" if baseline else route_question(question)
answer = (
generate_baseline_answer(question, points)
if baseline
else generate_answer(question, route, points)
)
print(f"\n[Route] {route}")
print("[Sources]")
for index, point in enumerate(points, start=1):
payload = point.payload or {}
source = payload.get("source_url", "Missing source")
print(f" [{index}] {source} (score={point.score:.3f})")
print("\n[Answer]")
print(answer)
What Does This Code Do?
- The collection check stops questions from running without the local knowledge base.
- The retrieval call keeps qwen3-embedding:0.6b responsible only for query vectors.
- The route expression preserves GENERIC for baseline runs. Every other run calls the coordinator.
- The answer expression sends routed questions to generate_answer() so the chosen specialist prompt reaches qwen3.5:4b.
- Save app.py.
✔️ Awesome, I've got everything!
Your command-line modes now connect the existing coordinator to the specialist answer path. Keep app.py saved for the route checks below.
ⓧ I'd like to double check the full code
import argparse
import json
from ollama import Client
from qdrant_client import QdrantClient
from prompts import (
BASELINE_PROMPT,
ROUTE_DESCRIPTIONS,
SPECIALIST_PROMPTS,
router_system_prompt,
)
QDRANT_URL = "http://localhost:6333"
OLLAMA_URL = "http://localhost:11434"
COLLECTION_NAME = "kanto_prompt_router"
CHAT_MODEL = "qwen3.5:4b"
EMBEDDING_MODEL = "qwen3-embedding:0.6b"
RETRIEVAL_TASK = (
"Given a Pokémon question, retrieve relevant Pokédex facts and "
"type matchup passages that answer it"
)
ollama_client = Client(host=OLLAMA_URL)
qdrant_client = QdrantClient(url=QDRANT_URL)
def route_schema() -> dict:
return {
"type": "object",
"properties": {
"route": {
"type": "string",
"enum": list(ROUTE_DESCRIPTIONS),
}
},
"required": ["route"],
"additionalProperties": False,
}
def route_question(question: str) -> str:
response = ollama_client.chat(
model=CHAT_MODEL,
messages=[
{"role": "system", "content": router_system_prompt()},
{"role": "user", "content": question},
],
format=route_schema(),
options={"temperature": 0},
)
try:
route = json.loads(response.message.content or "{}").get("route", "FALLBACK")
except json.JSONDecodeError:
return "FALLBACK"
return route if route in ROUTE_DESCRIPTIONS else "FALLBACK"
def retrieve(question: str, limit: int = 6):
query_text = f"Instruct: {RETRIEVAL_TASK}\nQuery:{question}"
query_vector = ollama_client.embed(
model=EMBEDDING_MODEL,
input=query_text,
).embeddings[0]
return qdrant_client.query_points(
collection_name=COLLECTION_NAME,
query=list(query_vector),
with_payload=True,
limit=limit,
).points
def format_context(points) -> str:
sections = []
for index, point in enumerate(points, start=1):
payload = point.payload or {}
sections.append(
f"[{index}] {payload.get('text', 'Missing text')}\n"
f"Source: {payload.get('source_url', 'Missing source')}"
)
return "\n\n".join(sections)
def generate_answer(question: str, route: str, points) -> str:
prompt = SPECIALIST_PROMPTS.get(route, SPECIALIST_PROMPTS["FALLBACK"])
response = ollama_client.chat(
model=CHAT_MODEL,
messages=[
{"role": "system", "content": prompt},
{
"role": "user",
"content": (
f"Question:\n{question}\n\n"
f"Retrieved context:\n{format_context(points)}"
),
},
],
options={"temperature": 0.2},
)
return response.message.content or "The model returned no answer."
def generate_baseline_answer(question: str, points) -> str:
response = ollama_client.chat(
model=CHAT_MODEL,
messages=[
{"role": "system", "content": BASELINE_PROMPT},
{
"role": "user",
"content": (
f"Question:\n{question}\n\n"
f"Retrieved context:\n{format_context(points)}"
),
},
],
options={"temperature": 0.2},
)
return response.message.content or "The model returned no answer."
def print_result(question: str, baseline: bool = False) -> None:
if not qdrant_client.collection_exists(COLLECTION_NAME):
raise RuntimeError("Run 'python ingest.py' before asking questions.")
points = retrieve(question)
route = "GENERIC" if baseline else route_question(question)
answer = (
generate_baseline_answer(question, points)
if baseline
else generate_answer(question, route, points)
)
print(f"\n[Route] {route}")
print("[Sources]")
for index, point in enumerate(points, start=1):
payload = point.payload or {}
source = payload.get("source_url", "Missing source")
print(f" [{index}] {source} (score={point.score:.3f})")
print("\n[Answer]")
print(answer)
def interactive_chat() -> None:
print("Kanto Prompt Router")
print("Type 'quit' to exit.")
while True:
question = input("\nYou: ").strip()
if question.lower() in {"quit", "exit"}:
break
if question:
print_result(question)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Route Pokémon questions to grounded specialist prompts."
)
group = parser.add_mutually_exclusive_group()
group.add_argument("--baseline", metavar="QUESTION")
group.add_argument("--route", metavar="QUESTION")
group.add_argument("--ask", metavar="QUESTION")
return parser
def main() -> None:
args = build_parser().parse_args()
if args.baseline:
print_result(args.baseline, baseline=True)
elif args.route:
print(route_question(args.route))
elif args.ask:
print_result(args.ask)
else:
interactive_chat()
if __name__ == "__main__":
main()
The route-only check selected COMPARE. Before you request the complete answer, do you expect the displayed route to stay the same?
- Generate the routed comparison answer by running:
python app.py --ask "Compare Charizard and Blastoise."
You should see [Route] COMPARE above the retrieved source URLs. The answer should use a comparison-focused structure grounded in those sources.
Before the final check, which route should a type-strength question select?
- Run the battle-focused question by running:
python app.py --ask "Which types are strong against Charizard?"
You should see [Route] BATTLE followed by retrieved PokéAPI source URLs. The answer should focus on type relationships supported by the retrieved records.
Answer Still Using the Wrong Specialist?
- Confirm that print_result() calls route_question(question) when baseline mode is off.
- Confirm that generate_answer() selects its prompt from SPECIALIST_PROMPTS with the route label.
- Confirm that the local Qdrant collection still exists before repeating the command.
Need help tracing the two calls? Help me debug my coordinator and specialist answer path.
Your assistant now changes its instructions to match each requested job while keeping one chat model. Next, you will evaluate every route with a repeatable test matrix.
Evaluate the Router and Grounding
You proved that battle questions can reach BATTLE. You also proved that comparison questions can reach COMPARE.
A reliable prompt router needs observable behavior across every branch. This step turns four representative questions into a repeatable evaluation.
In this step, get ready to:
- Create an evaluation script for factual, comparison, battle, and off-topic requests.
- Run the route matrix to compare expected labels with actual labels.
- Confirm that the interactive assistant grounds a supported answer and handles an off-topic request gracefully.
Build the route evaluation matrix
One successful route proves a single example. A route matrix checks the same coordinator against every specialist branch.
- In the file sidebar in VS Code, use the new-file control to create evaluate.py inside pokemon-prompt-router.
- Fill evaluate.py with the evaluation matrix by copying this code:
from app import route_question
CASES = [
("What are Pikachu's base stats?", "POKEDEX"),
("Compare Charizard and Blastoise.", "COMPARE"),
("Which types are strong against Charizard?", "BATTLE"),
("Write a SQL query for a customer table.", "FALLBACK"),
]
def main() -> None:
passed = 0
for question, expected in CASES:
actual = route_question(question)
status = "PASS" if actual == expected else "CHECK"
passed += actual == expected
print(f"{status}: expected={expected:<8} actual={actual:<8} question={question}")
print(f"\nRoute tests: {passed}/{len(CASES)} matched the expected specialist.")
if __name__ == "__main__":
main()
How does the evaluation work?
- The CASES list pairs each representative question with its expected specialist.
- The main() function sends every question through the real route_question coordinator.
- A matching label produces PASS. A different label produces CHECK so you can inspect the variation.
- The final line summarizes how many questions reached their expected specialists.
- Save evaluate.py.
Before you run the matrix, how many of the four questions do you expect to reach their intended specialists?
- Evaluate all four routes from the existing PowerShell terminal by running:
python evaluate.py
How to read the results
- Each result shows the expected route beside the route selected by the coordinator.
- A PASS confirms that both labels match.
- A CHECK exposes a route mismatch for later inspection.
- The last line starts with Route tests: and reports the total number of matches.
Good work. Your router now has a repeatable test matrix covering all four current branches.
Seeing a CHECK result?
A CHECK result records a different classification from the expected route. The script continues so you can compare every case.
- Confirm that prompts.py still contains distinct descriptions for POKEDEX, COMPARE, BATTLE, and FALLBACK.
- Confirm that evaluate.py preserves each question and expected label exactly.
Still stuck? Help me diagnose a route mismatch in evaluate.py.
✔️ Awesome, I've got everything!
Your saved evaluate.py now checks all four route categories and prints a route-test summary.
ⓧ I'd like to double check the full code
- Compare your saved evaluate.py with this complete file:
from app import route_question
CASES = [
("What are Pikachu's base stats?", "POKEDEX"),
("Compare Charizard and Blastoise.", "COMPARE"),
("Which types are strong against Charizard?", "BATTLE"),
("Write a SQL query for a customer table.", "FALLBACK"),
]
def main() -> None:
passed = 0
for question, expected in CASES:
actual = route_question(question)
status = "PASS" if actual == expected else "CHECK"
passed += actual == expected
print(f"{status}: expected={expected:<8} actual={actual:<8} question={question}")
print(f"\nRoute tests: {passed}/{len(CASES)} matched the expected specialist.")
if __name__ == "__main__":
main()
Check a grounded specialist answer
Grounding ties the answer to retrieved records instead of unsupported model knowledge. A supported question should expose its selected route and its evidence.
- Start the interactive assistant from the existing PowerShell terminal by running:
python app.py
What does this command do?
This starts interactive_chat() from app.py. The assistant keeps using the terminal until you enter quit.
You'll see Kanto Prompt Router followed by a You: prompt. The first local response can take longer while the model gets ready.
Before you submit the question, which specialist do you expect to handle a direct request for Pikachu's base stats?
- Submit a supported factual question by entering:
What are Pikachu's base stats?
What should this prove?
- The [Route] POKEDEX label confirms that the factual specialist handled the question.
- The [Sources] section exposes retrieved PokéAPI URLs.
- The [Answer] section uses source labels to connect Pikachu's facts to retrieved evidence.
That result links the selected specialist to visible retrieval evidence. Your routed answer can now be inspected instead of trusted blindly.
Missing the grounded answer?
- Confirm that the existing kanto_prompt_router collection remains available on the local server.
- Confirm that the local model service is still running if the assistant stops before returning a route.
- Submit the factual question exactly as shown if the result uses an unexpected specialist.
Need a hand? Help me troubleshoot why app.py did not show a grounded POKEDEX answer.
Verify the graceful fallback
The fallback specialist protects the field guide's scope. It should decline an unrelated task without inventing an answer from general model knowledge.
Before you submit the next question, do you expect the assistant to write the requested SQL or hand the request to its gatekeeper?
- Submit the off-topic evaluation question at the next You: prompt by entering:
Write a SQL query for a customer table.
What should you see?
- The [Route] FALLBACK label confirms that the coordinator recognized an out-of-scope request.
- The answer explains that the assistant is limited to its indexed Pokémon records.
- The response avoids generating the requested SQL query.
Did another route answer the SQL question?
- Submit the exact SQL question shown above to remove wording differences from the check.
- Confirm that prompts.py still includes the FALLBACK route description and Field Guide Gatekeeper prompt.
Still seeing an in-scope answer? Help me diagnose why the SQL question did not select FALLBACK.
- Exit the interactive assistant by entering quit at the next You: prompt.
You now have repeatable route evidence plus a manual grounding check. The router exposes how it handles supported requests and protects its scope when a request falls outside the field guide.
Secret mission
Add a Team Coach Route
Extend the prompt catalog with a Team Coach that reviews named Pokémon for type overlap, coverage gaps, defensive gaps, and complementary stat roles. Prove that a new specialist can join the router without changing the routing engine in app.py.
Clean Up Your Resources
Clean Up Your Resources
All project work runs on your computer, so there are no metered cloud charges. Decide whether to keep the local data ready, pause the assistant, or remove the project resources to recover disk space.
Resources you used:
- The Qdrant collection kanto_prompt_router containing Pokémon records and type-matchup records.
- The pokemon-prompt-router folder containing your source files and pinned dependencies.
- The .venv Python virtual environment inside the project folder.
Keep everything running
No action needed. Choose this if you are still testing routes or extending the specialist prompt catalog.
- Leave the kanto_prompt_router collection in place for future questions.
- Keep the pokemon-prompt-router folder open in VS Code.
- Keep both Ollama models qwen3.5:4b and qwen3-embedding:0.6b available for later runs.
Pause - I'll come back to this later
Stop the interactive assistant to free its current process. Your collection and project files stay ready for another session.
- Type quit at the assistant prompt.
You should return to the PowerShell prompt. Your indexed Pokémon records remain in place.
- Switch back to the PowerShell terminal from earlier when you are ready to continue.
- Restart the interactive assistant by running this command:
python app.py
What does this command do?
This command starts the interactive chat in app.py.
The app reconnects to kanto_prompt_router and reuses your specialist prompts.
Delete - I don't want to use this again
Deleting these resources is permanent, so use this option only when you no longer need the code or indexed records. Your downloaded Ollama models remain available to other local projects.
- Return to the activated PowerShell terminal in pokemon-prompt-router.
- Delete the project collection by running this command:
python -c "from qdrant_client import QdrantClient; QdrantClient(url='http://localhost:6333').delete_collection(collection_name='kanto_prompt_router')"
What does this command delete?
The command connects to your local Qdrant server. It deletes the kanto_prompt_router collection with its stored vectors and payloads.
Other Qdrant collections remain untouched. Your Ollama models also remain available.
- Return to the Qdrant dashboard from earlier.
- Refresh the dashboard.
You should no longer see kanto_prompt_router in the collection list. Your indexed Pokémon data is now cleared from Qdrant.
Collection still visible?
- Confirm the local Qdrant dashboard still loads.
- Rerun the deletion command from the activated .venv PowerShell terminal.
- Help me diagnose why my local Qdrant collection was not deleted.
- Close VS Code after the collection disappears.
- In File Explorer, go to the location containing pokemon-prompt-router.
- Delete the pokemon-prompt-router folder.
- Confirm the folder no longer appears in that File Explorer location.
Nice Work!
Nice Work!
You did it! Your local Pokémon RAG assistant now retrieves evidence from Qdrant before selecting a grounded specialist prompt.
You've learned how to:
- Build a sourced knowledge base from PokéAPI records using qwen3-embedding:0.6b embeddings and Qdrant vector search.
- Replace one generic prompt with a coordinator that selects the POKEDEX, COMPARE, BATTLE, or FALLBACK specialist while every chat call continues to use qwen3.5:4b through Ollama.
- Evaluate route behavior across factual, comparison, battle, and off-topic questions with a repeatable test matrix. Trace each answer through its visible route label and retrieved source URLs.
- Complete the Secret Mission: add a grounded Team Coach route for lineup coverage reviews without changing the routing engine in app.py.
Ready to quiz yourself?