On this page
The previous article ended on the actual problem with a pure chat wrapper: the model has no way to check a claim against the real dataset, so it can’t tell a true answer from a plausible one. The fix isn’t a smarter prompt. It’s giving the model something to call that returns real data from movies.csv, so an answer can be grounded in an actual lookup instead of a guess.
The tools
MovieTools is four search and filter functions over a pandas DataFrame, each returning a formatted string.
# phase-3/src/tools.py
class MovieTools:
def __init__(self, csv_path: str):
self.df = pd.read_csv(csv_path)
def search_movies(self, query: str, limit: int = 5) -> str:
query_lower = query.lower()
title_matches = self.df[self.df['title'].str.lower().str.contains(query_lower, na=False)]
plot_matches = self.df[self.df['plot'].str.lower().str.contains(query_lower, na=False)]
results = pd.concat([title_matches, plot_matches]).drop_duplicates(subset=['title'])
results = results.head(limit)
if len(results) == 0:
return f"❌ No movies found matching '{query}'"
return self._format_results(results)
def filter_by_language(self, language: str, limit: int = 5) -> str:
results = self.df[self.df['language'].str.lower() == language.lower()]
results = results.head(limit)
if len(results) == 0:
available = self.df['language'].unique()
return f"❌ No movies found in language '{language}'. Available: {', '.join(available)}"
return self._format_results(results)
def filter_by_rating(self, min_rating: float = 7.0, limit: int = 5) -> str:
results = self.df[self.df['imdb_rating'] >= min_rating]
results = results.sort_values('imdb_rating', ascending=False)
results = results.head(limit)
if len(results) == 0:
return f"❌ No movies found with rating >= {min_rating}"
return self._format_results(results)
(filter_by_genre follows the same shape as filter_by_language, substring-matching against the genre column instead of an exact match against language.) Nothing here calls an LLM. That’s worth sitting with for a second: this is plain pandas filtering with a few str.contains calls, wrapped in functions with clear names and typed arguments. It becomes a tool an agent can call the moment it’s registered as one, but the function itself doesn’t know or care that an LLM will ever touch it. That separation is what makes it easy to test well.
Testing tools before there’s an agent
Because these are just functions over a DataFrame, they’re deterministic: same input, same output, every time. That means real assertions, not “did it respond reasonably.”
# tests/test_tools.py
import pytest
from tools import MovieTools
@pytest.fixture
def tools():
return MovieTools("data/movies.csv")
def test_search_finds_exact_title(tools):
results = tools.search_movies("Lagaan")
assert "Lagaan" in results
def test_search_is_case_insensitive(tools):
lower = tools.search_movies("lagaan")
upper = tools.search_movies("LAGAAN")
assert lower == upper
def test_search_with_no_matches_returns_a_clear_message(tools):
results = tools.search_movies("xyzabc123")
assert "No movies found" in results
def test_filter_by_language_rejects_unknown_language(tools):
results = tools.filter_by_language("Klingon")
assert "No movies found" in results
assert "Available" in results # tells the caller what actually exists
def test_filter_by_rating_only_returns_matches_at_or_above_the_floor(tools):
results = tools.filter_by_rating(min_rating=8.0, limit=10)
ratings = [float(line.split("Rating:")[1].split("|")[0].strip())
for line in results.split("\n") if "Rating:" in line]
assert all(r >= 8.0 for r in ratings)
The last test is the one worth paying attention to. It doesn’t just check that filter_by_rating returns something: it parses the formatted output back out and verifies every single result actually satisfies the filter. That’s the difference between “the function ran” and “the function is correct,” and it’s only possible to check because the output is deterministic. None of these five tests need an API key, cost anything, or take more than milliseconds to run. That’s exactly the kind of coverage that should exist before an LLM ever enters the picture, because once it does, every bug in these functions becomes a bug the agent inherits without any indication of where it came from.
Where this breaks down
filter_by_language does an exact match (== language.lower()) while search_movies and filter_by_genre do a substring match (.str.contains(...)). That inconsistency is invisible until someone calls filter_by_language("Tamil ") with a trailing space, or "tamil" when the data stores "Tamil" (both handled fine by the lowercase comparison), versus calling it with a language that’s a substring of another, which the exact-match version handles correctly but would silently over-match if it were written the same way as the genre filter. It’s not a bug today, because the test suite above locks in the current behavior, but it’s exactly the kind of inconsistency that becomes a real bug the day someone “fixes” one function to match the other’s style without checking both call sites.
The bigger risk shows up once these functions become tools an LLM calls with arguments it generates. search_movies(query=""), an empty string, passes .str.contains('', na=False), which matches every row in the dataset and returns whatever limit rows happen to be first. That’s not a crash, which makes it worse: it’s a tool that silently returns the wrong thing for a degenerate input, and the agent has no way to know the results it just got back don’t actually mean anything.
def test_search_with_empty_query_does_not_silently_match_everything():
tools = MovieTools("data/movies.csv")
results = tools.search_movies("")
# An empty query matching every row is a real bug waiting for an LLM
# to pass one - decide what SHOULD happen and assert it explicitly.
assert "No movies found" in results
That test currently fails against the code as written, which is the point: it documents a decision that hasn’t been made yet, rather than letting the gap stay invisible until an agent hits it in production.
Takeaways
- The step that turns a chatbot into something that can be right or wrong isn’t a bigger model. It’s giving the model a function that returns real data, deterministic and independent of any LLM.
- Test tool functions as plain functions, before an agent ever calls them. They’re deterministic, they’re fast, and every bug caught here is a bug the agent never has to be blamed for.
- Inconsistent matching behavior between similar-looking functions (exact match here, substring match there) and unhandled edge cases (empty query, empty filter value) are invisible in a demo and real the moment an LLM starts generating the arguments instead of a human typing them carefully.
