| """
|
| LangGraph-based Car Finder Chatbot
|
|
|
| This implementation uses:
|
| - Template-based SQL queries (no SQL generation)
|
| - LangGraph for agent orchestration
|
| - Tool-based architecture for security
|
| - State management for conversation flow
|
| """
|
|
|
| from typing import TypedDict, Annotated, Optional, Literal
|
| from operator import add
|
| import sqlite3
|
| import os
|
| from dotenv import load_dotenv
|
|
|
| from langchain_openai import ChatOpenAI
|
| from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
|
| from langchain_core.tools import tool
|
| from langgraph.graph import StateGraph, END
|
| from langgraph.prebuilt import ToolNode
|
| from pydantic import BaseModel, Field
|
|
|
|
|
| load_dotenv()
|
|
|
|
|
| api_key = os.environ.get("OPENAI_API_KEY")
|
| if not api_key:
|
| raise ValueError("OPENAI_API_KEY environment variable is not set")
|
|
|
|
|
| llm = ChatOpenAI(model="gpt-4o", temperature=0.7, api_key=api_key)
|
|
|
|
|
| try:
|
| with open('database_schema.txt', 'r') as f:
|
| SCHEMA_DESCRIPTION = f.read()
|
| except FileNotFoundError:
|
| raise FileNotFoundError("database_schema.txt not found. Please ensure it exists in the current directory.")
|
|
|
|
|
| MIN_RESULTS = 1
|
| MAX_RESULTS = 20
|
| DB_PATH = 'cars.db'
|
|
|
|
|
|
|
|
|
|
|
|
|
| class SearchParameters(BaseModel):
|
| """Parameters for searching cars using template-based SQL"""
|
| min_price: Optional[int] = Field(None, description="Minimum price in USD", ge=0, le=100000)
|
| max_price: Optional[int] = Field(None, description="Maximum price in USD", ge=0, le=100000)
|
| fuel_type: Optional[Literal["Gasoline", "Diesel", "Electric", "Hybrid", "Plug-in Hybrid"]] = Field(None, description="Type of fuel")
|
| is_suv: Optional[bool] = Field(None, description="True for SUVs, False for sedans/coupes")
|
| min_seating: Optional[int] = Field(None, description="Minimum seating capacity", ge=4, le=8)
|
| max_seating: Optional[int] = Field(None, description="Maximum seating capacity", ge=4, le=8)
|
| drivetrain: Optional[Literal["FWD", "RWD", "AWD", "4WD"]] = Field(None, description="Drive system")
|
| min_fuel_efficiency_city: Optional[float] = Field(None, description="Minimum city MPG", ge=0)
|
| min_cargo_space: Optional[int] = Field(None, description="Minimum cargo space in cubic feet", ge=0)
|
| has_sunroof: Optional[bool] = Field(None, description="Must have sunroof")
|
| has_leather_seats: Optional[bool] = Field(None, description="Must have leather seats")
|
| has_navigation: Optional[bool] = Field(None, description="Must have navigation system")
|
| has_backup_camera: Optional[bool] = Field(None, description="Must have backup camera")
|
| min_safety_rating: Optional[float] = Field(None, description="Minimum safety rating", ge=0, le=5)
|
|
|
|
|
|
|
|
|
|
|
|
|
| @tool
|
| def search_cars(
|
| min_price: Optional[int] = None,
|
| max_price: Optional[int] = None,
|
| fuel_type: Optional[str] = None,
|
| is_suv: Optional[bool] = None,
|
| min_seating: Optional[int] = None,
|
| max_seating: Optional[int] = None,
|
| drivetrain: Optional[str] = None,
|
| min_fuel_efficiency_city: Optional[float] = None,
|
| min_cargo_space: Optional[int] = None,
|
| has_sunroof: Optional[bool] = None,
|
| has_leather_seats: Optional[bool] = None,
|
| has_navigation: Optional[bool] = None,
|
| has_backup_camera: Optional[bool] = None,
|
| min_safety_rating: Optional[float] = None,
|
| ) -> dict:
|
| """
|
| Search for cars using a secure template-based SQL query.
|
|
|
| Returns a dictionary with:
|
| - count: number of cars found
|
| - cars: list of matching cars (limited to first 20)
|
| - status: 'too_few', 'good', or 'too_many'
|
| """
|
|
|
| conditions = []
|
| params = []
|
|
|
| if min_price is not None:
|
| conditions.append("price >= ?")
|
| params.append(min_price)
|
|
|
| if max_price is not None:
|
| conditions.append("price <= ?")
|
| params.append(max_price)
|
|
|
| if fuel_type is not None:
|
| conditions.append("fuel_type = ?")
|
| params.append(fuel_type)
|
|
|
| if is_suv is not None:
|
| conditions.append("is_suv = ?")
|
| params.append(1 if is_suv else 0)
|
|
|
| if min_seating is not None:
|
| conditions.append("seating_capacity >= ?")
|
| params.append(min_seating)
|
|
|
| if max_seating is not None:
|
| conditions.append("seating_capacity <= ?")
|
| params.append(max_seating)
|
|
|
| if drivetrain is not None:
|
| conditions.append("drivetrain = ?")
|
| params.append(drivetrain)
|
|
|
| if min_fuel_efficiency_city is not None:
|
| conditions.append("fuel_efficiency_city >= ?")
|
| params.append(min_fuel_efficiency_city)
|
|
|
| if min_cargo_space is not None:
|
| conditions.append("cargo_space >= ?")
|
| params.append(min_cargo_space)
|
|
|
| if has_sunroof is not None:
|
| conditions.append("has_sunroof = ?")
|
| params.append(1 if has_sunroof else 0)
|
|
|
| if has_leather_seats is not None:
|
| conditions.append("has_leather_seats = ?")
|
| params.append(1 if has_leather_seats else 0)
|
|
|
| if has_navigation is not None:
|
| conditions.append("has_navigation = ?")
|
| params.append(1 if has_navigation else 0)
|
|
|
| if has_backup_camera is not None:
|
| conditions.append("has_backup_camera = ?")
|
| params.append(1 if has_backup_camera else 0)
|
|
|
| if min_safety_rating is not None:
|
| conditions.append("safety_rating >= ?")
|
| params.append(min_safety_rating)
|
|
|
|
|
| where_clause = " AND ".join(conditions) if conditions else "1=1"
|
| query = f"SELECT * FROM cars WHERE {where_clause} ORDER BY price LIMIT 21"
|
|
|
| try:
|
|
|
| with sqlite3.connect(DB_PATH) as conn:
|
| conn.row_factory = sqlite3.Row
|
| cursor = conn.cursor()
|
| cursor.execute(query, params)
|
| results = cursor.fetchall()
|
|
|
|
|
| cars = [dict(row) for row in results]
|
| count = len(cars)
|
|
|
|
|
| if count < MIN_RESULTS:
|
| status = "too_few"
|
| elif count > MAX_RESULTS:
|
| status = "too_many"
|
| cars = cars[:MAX_RESULTS]
|
| else:
|
| status = "good"
|
|
|
| return {
|
| "count": count,
|
| "cars": cars,
|
| "status": status,
|
| "params_used": {k: v for k, v in [
|
| ("min_price", min_price),
|
| ("max_price", max_price),
|
| ("fuel_type", fuel_type),
|
| ("is_suv", is_suv),
|
| ("min_seating", min_seating),
|
| ("max_seating", max_seating),
|
| ("drivetrain", drivetrain),
|
| ("min_fuel_efficiency_city", min_fuel_efficiency_city),
|
| ("min_cargo_space", min_cargo_space),
|
| ("has_sunroof", has_sunroof),
|
| ("has_leather_seats", has_leather_seats),
|
| ("has_navigation", has_navigation),
|
| ("has_backup_camera", has_backup_camera),
|
| ("min_safety_rating", min_safety_rating),
|
| ] if v is not None}
|
| }
|
|
|
| except sqlite3.Error as e:
|
| return {
|
| "count": 0,
|
| "cars": [],
|
| "status": "error",
|
| "error": str(e)
|
| }
|
|
|
|
|
| @tool
|
| def count_cars_only(
|
| min_price: Optional[int] = None,
|
| max_price: Optional[int] = None,
|
| fuel_type: Optional[str] = None,
|
| is_suv: Optional[bool] = None,
|
| min_seating: Optional[int] = None,
|
| max_seating: Optional[int] = None,
|
| drivetrain: Optional[str] = None,
|
| min_fuel_efficiency_city: Optional[float] = None,
|
| min_cargo_space: Optional[int] = None,
|
| has_sunroof: Optional[bool] = None,
|
| has_leather_seats: Optional[bool] = None,
|
| has_navigation: Optional[bool] = None,
|
| has_backup_camera: Optional[bool] = None,
|
| min_safety_rating: Optional[float] = None,
|
| ) -> dict:
|
| """
|
| Count how many cars match the criteria without returning full results.
|
| Useful for checking if we need to refine search parameters.
|
| """
|
|
|
| conditions = []
|
| params = []
|
|
|
| if min_price is not None:
|
| conditions.append("price >= ?")
|
| params.append(min_price)
|
|
|
| if max_price is not None:
|
| conditions.append("price <= ?")
|
| params.append(max_price)
|
|
|
| if fuel_type is not None:
|
| conditions.append("fuel_type = ?")
|
| params.append(fuel_type)
|
|
|
| if is_suv is not None:
|
| conditions.append("is_suv = ?")
|
| params.append(1 if is_suv else 0)
|
|
|
| if min_seating is not None:
|
| conditions.append("seating_capacity >= ?")
|
| params.append(min_seating)
|
|
|
| if max_seating is not None:
|
| conditions.append("seating_capacity <= ?")
|
| params.append(max_seating)
|
|
|
| if drivetrain is not None:
|
| conditions.append("drivetrain = ?")
|
| params.append(drivetrain)
|
|
|
| if min_fuel_efficiency_city is not None:
|
| conditions.append("fuel_efficiency_city >= ?")
|
| params.append(min_fuel_efficiency_city)
|
|
|
| if min_cargo_space is not None:
|
| conditions.append("cargo_space >= ?")
|
| params.append(min_cargo_space)
|
|
|
| if has_sunroof is not None:
|
| conditions.append("has_sunroof = ?")
|
| params.append(1 if has_sunroof else 0)
|
|
|
| if has_leather_seats is not None:
|
| conditions.append("has_leather_seats = ?")
|
| params.append(1 if has_leather_seats else 0)
|
|
|
| if has_navigation is not None:
|
| conditions.append("has_navigation = ?")
|
| params.append(1 if has_navigation else 0)
|
|
|
| if has_backup_camera is not None:
|
| conditions.append("has_backup_camera = ?")
|
| params.append(1 if has_backup_camera else 0)
|
|
|
| if min_safety_rating is not None:
|
| conditions.append("safety_rating >= ?")
|
| params.append(min_safety_rating)
|
|
|
| where_clause = " AND ".join(conditions) if conditions else "1=1"
|
| query = f"SELECT COUNT(*) as count FROM cars WHERE {where_clause}"
|
|
|
| try:
|
| with sqlite3.connect(DB_PATH) as conn:
|
| cursor = conn.cursor()
|
| cursor.execute(query, params)
|
| count = cursor.fetchone()[0]
|
|
|
| return {
|
| "count": count,
|
| "status": "too_few" if count < MIN_RESULTS else "too_many" if count > MAX_RESULTS else "good"
|
| }
|
|
|
| except sqlite3.Error as e:
|
| return {
|
| "count": 0,
|
| "status": "error",
|
| "error": str(e)
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| class ConversationState(TypedDict):
|
| """State for the conversation graph"""
|
| messages: Annotated[list, add]
|
| search_params: Optional[dict]
|
| search_results: Optional[dict]
|
| iteration_count: int
|
| user_satisfied: bool
|
| requires_search: bool
|
|
|
|
|
|
|
|
|
|
|
|
|
| def gather_requirements(state: ConversationState) -> ConversationState:
|
| """
|
| Node: Gather requirements from user and determine search parameters.
|
| This node uses LLM to extract search criteria from conversation.
|
| """
|
| messages = state["messages"]
|
|
|
| system_prompt = f"""You are a friendly car shopping advisor. Analyze the conversation and extract search parameters.
|
|
|
| {SCHEMA_DESCRIPTION}
|
|
|
| Based on the user's requirements, determine:
|
| 1. What search parameters should be used (price range, fuel type, SUV/sedan, features, etc.)
|
| 2. Whether we have enough information to search (set requires_search=true)
|
| 3. Whether the user is satisfied with current results (set user_satisfied=true)
|
|
|
| If the user just greeted you or asked a general question, be friendly and ask what they're looking for.
|
| If previous search returned too few/many results, suggest adjustments.
|
|
|
| You have access to these tools:
|
| - search_cars: Search for cars with specific parameters
|
| - count_cars_only: Check count before full search
|
|
|
| IMPORTANT: Only use tools when you have concrete search criteria.
|
| For greetings or clarifying questions, just respond conversationally without calling tools."""
|
|
|
|
|
| llm_with_tools = llm.bind_tools([search_cars, count_cars_only])
|
| response = llm_with_tools.invoke([SystemMessage(content=system_prompt)] + messages)
|
|
|
|
|
| new_state = {
|
| "messages": [response],
|
| "requires_search": bool(response.tool_calls),
|
| "iteration_count": state.get("iteration_count", 0)
|
| }
|
|
|
|
|
| if "satisfied" in response.content.lower() or "perfect" in response.content.lower():
|
| new_state["user_satisfied"] = True
|
|
|
| return new_state
|
|
|
|
|
| def execute_search(state: ConversationState) -> ConversationState:
|
| """
|
| Node: Execute the search using tool calls from the LLM.
|
| """
|
| messages = state["messages"]
|
| last_message = messages[-1]
|
|
|
|
|
| if hasattr(last_message, 'tool_calls') and last_message.tool_calls:
|
| tool_node = ToolNode([search_cars, count_cars_only])
|
| result = tool_node.invoke(state)
|
|
|
|
|
| tool_messages = result["messages"]
|
| if tool_messages:
|
|
|
| tool_response = tool_messages[-1]
|
| if hasattr(tool_response, 'content'):
|
| import json
|
| try:
|
| search_results = json.loads(tool_response.content)
|
| except:
|
| search_results = {"error": "Failed to parse tool response"}
|
| else:
|
| search_results = {}
|
| else:
|
| search_results = {}
|
|
|
| return {
|
| "messages": tool_messages,
|
| "search_results": search_results,
|
| "iteration_count": state.get("iteration_count", 0) + 1
|
| }
|
|
|
| return {"iteration_count": state.get("iteration_count", 0)}
|
|
|
|
|
| def present_results(state: ConversationState) -> ConversationState:
|
| """
|
| Node: Present search results to user and provide guidance.
|
| """
|
| search_results = state.get("search_results", {})
|
| messages = state["messages"]
|
|
|
| system_prompt = """You are presenting car search results to the user.
|
|
|
| Based on the search results:
|
| - If status is 'good' (1-20 cars): Present the results enthusiastically and ask if they want details
|
| - If status is 'too_few' (<1 cars): Suggest broadening criteria (increase price range, consider more fuel types, etc.)
|
| - If status is 'too_many' (>20 cars): Suggest narrowing criteria (set budget, choose specific features, etc.)
|
| - If status is 'error': Apologize and ask them to rephrase
|
|
|
| Be conversational, helpful, and guide the user toward finding their perfect car."""
|
|
|
|
|
| context = f"\nSearch Results: {search_results}"
|
|
|
| response = llm.invoke([
|
| SystemMessage(content=system_prompt),
|
| *messages,
|
| HumanMessage(content=context)
|
| ])
|
|
|
| return {"messages": [response]}
|
|
|
|
|
| def should_continue(state: ConversationState) -> Literal["execute_search", "present_results", "end"]:
|
| """
|
| Conditional edge: Determine next step in the graph.
|
| """
|
|
|
| if state.get("user_satisfied", False):
|
| return "end"
|
|
|
|
|
| if state.get("requires_search", False) and not state.get("search_results"):
|
| return "execute_search"
|
|
|
|
|
| if state.get("search_results"):
|
| return "present_results"
|
|
|
|
|
| return "end"
|
|
|
|
|
|
|
|
|
|
|
|
|
| def build_graph() -> StateGraph:
|
| """Build the LangGraph workflow"""
|
|
|
| workflow = StateGraph(ConversationState)
|
|
|
|
|
| workflow.add_node("gather_requirements", gather_requirements)
|
| workflow.add_node("execute_search", execute_search)
|
| workflow.add_node("present_results", present_results)
|
|
|
|
|
| workflow.set_entry_point("gather_requirements")
|
|
|
|
|
| workflow.add_conditional_edges(
|
| "gather_requirements",
|
| should_continue,
|
| {
|
| "execute_search": "execute_search",
|
| "present_results": "present_results",
|
| "end": END
|
| }
|
| )
|
|
|
|
|
| workflow.add_edge("execute_search", "present_results")
|
|
|
|
|
| workflow.add_edge("present_results", END)
|
|
|
| return workflow.compile()
|
|
|
|
|
|
|
|
|
|
|
|
|
| def format_car_display(car: dict) -> str:
|
| """Format a single car for display"""
|
| return f"""
|
| {car['brand']} {car['model_name']} ({car['year']})
|
| Price: ${car['price']:,}
|
| Type: {'SUV' if car['is_suv'] else 'Sedan/Coupe'}
|
| Fuel: {car['fuel_type']}
|
| Seats: {car['seating_capacity']} | Cargo: {car['cargo_space']} cu ft
|
| Drivetrain: {car['drivetrain']} | Transmission: {car['transmission']}
|
| Features: {'Sunroof, ' if car['has_sunroof'] else ''}{'Leather, ' if car['has_leather_seats'] else ''}{'Navigation, ' if car['has_navigation'] else ''}{'Backup Camera' if car['has_backup_camera'] else ''}
|
| """
|
|
|
|
|
|
|
|
|
|
|
|
|
| def main():
|
| """Main chatbot loop using LangGraph"""
|
| print("=" * 60)
|
| print("Welcome to Car Finder Chatbot (LangGraph Edition)")
|
| print("=" * 60)
|
| print("Tell me what kind of car you're looking for!")
|
| print("Type 'quit' to exit.\n")
|
|
|
|
|
| app = build_graph()
|
|
|
|
|
| conversation_state = {
|
| "messages": [],
|
| "search_params": None,
|
| "search_results": None,
|
| "iteration_count": 0,
|
| "user_satisfied": False,
|
| "requires_search": False
|
| }
|
|
|
| while True:
|
|
|
| user_input = input("You: ").strip()
|
|
|
| if user_input.lower() in ['quit', 'exit', 'bye']:
|
| print("\nThanks for using Car Finder! Goodbye! 👋")
|
| break
|
|
|
| if not user_input:
|
| continue
|
|
|
|
|
| conversation_state["messages"].append(HumanMessage(content=user_input))
|
|
|
|
|
| try:
|
| result = app.invoke(conversation_state)
|
|
|
|
|
| conversation_state = result
|
|
|
|
|
| if result["messages"]:
|
| last_message = result["messages"][-1]
|
| if hasattr(last_message, 'content'):
|
| print(f"\nAssistant: {last_message.content}\n")
|
|
|
|
|
| if result.get("search_results", {}).get("status") == "good":
|
| cars = result["search_results"].get("cars", [])
|
| if cars:
|
| print("=" * 60)
|
| print("✅ MATCHING CARS:")
|
| print("=" * 60)
|
| for car in cars:
|
| print(format_car_display(car))
|
| print("=" * 60)
|
| print()
|
|
|
|
|
| if result.get("user_satisfied", False):
|
| print("\nThank you for using Car Finder! Have a great day! 👋")
|
| break
|
|
|
| except Exception as e:
|
| print(f"\n❌ Error: {str(e)}")
|
| print("Let's try again. Please rephrase your request.\n")
|
|
|
| conversation_state["messages"] = conversation_state["messages"][:-1]
|
|
|
|
|
| if __name__ == "__main__":
|
| main()
|
|
|