"""FoodHub Chat Agent — backend logic for the Streamlit chatbot app. This module initialises the LLM, SQL Agent, tools, and Chat Agent, and exposes run_chat_agent_query() as the single entry point for the Streamlit frontend. """ import os from typing import Annotated from dotenv import load_dotenv from langchain.agents import create_agent from langchain.chat_models import init_chat_model from langchain.tools import tool from langchain_community.agent_toolkits import SQLDatabaseToolkit from langchain_community.utilities import SQLDatabase from langchain_core.messages import HumanMessage, SystemMessage # Load environment variables — OPENAI_API_KEY is read from the environment. # Locally: set in .env file. On Hugging Face Space: set as a Space secret. load_dotenv() # --------------------------------------------------------------------------- # Model # --------------------------------------------------------------------------- # GPT-5-mini with medium reasoning — same configuration as used in the notebook. # temperature=0.2 for deterministic, accurate responses. model_gpt5_mini = init_chat_model( model="gpt-5-mini", model_provider="openai", temperature=0.2, max_tokens=1024, reasoning={"effort": "medium", "summary": "auto"}, ) # --------------------------------------------------------------------------- # Database # --------------------------------------------------------------------------- # Resolve the DB path relative to this file so the path is correct whether # the app is run locally or inside Docker (where the working directory may differ). _DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "customer_orders.db") db = SQLDatabase.from_uri(f"sqlite:///{_DB_PATH}") # --------------------------------------------------------------------------- # SQL Agent # --------------------------------------------------------------------------- SQL_AGENT_SYSTEM_PROMPT = f""" You are a SQL data retrieval agent for FoodHub's order management database. Your role is to translate customer order queries into SQL, execute them against the {db.dialect} database, and return structured results. Database schema notes (read carefully before querying): - All time columns (order_time, preparing_eta, prepared_time, delivery_eta, delivery_time) are stored as TEXT in 'HH:MM' format (e.g., '12:30'). To compute a time difference in minutes between two HH:MM columns, use: round((julianday('1970-01-01 ' || col_end) - julianday('1970-01-01 ' || col_start)) * 24 * 60) - The item_in_order column is a comma-separated TEXT string (e.g., 'Burger, Fries'). To count the number of items in an order, use: (length(item_in_order) - length(replace(item_in_order, ',', '')) + 1) - NULL values in time columns mean that stage has not been reached yet (e.g., delivery_time is NULL if the order has not yet been delivered). Query rules: 1. ALWAYS inspect the table schema before writing any query. 2. Query ONLY for the specific order_id provided — never return data for all orders at once. 3. ALWAYS include cust_id in the result for customer ownership verification. 4. Limit results to at most 5 rows unless explicitly instructed otherwise. 5. Double-check your SQL before executing. If an error occurs, rewrite and retry once. 6. DO NOT execute any DML statements (INSERT, UPDATE, DELETE, DROP, TRUNCATE, ALTER). Output format: - Return the result as a valid JSON object with column names as keys. - For computed fields (time differences, item counts), use descriptive keys (e.g., "prep_time_minutes", "item_count", "delivery_delay_minutes"). - If no record is found for the given order_id, return exactly: {{"error": "Order not found"}} - Represent NULL column values as null in the JSON. """ sql_toolkit = SQLDatabaseToolkit(db=db, llm=model_gpt5_mini) sql_tools = sql_toolkit.get_tools() sql_agent = create_agent( model=model_gpt5_mini, tools=sql_tools, system_prompt=SQL_AGENT_SYSTEM_PROMPT, ) def run_sql_agent_query(question: str) -> str: """Translate a natural language question into SQL, execute it, and return the result. Args: question (str): Natural language query about an order. Returns: str: JSON string with order data, or an error message if not found. """ events = list( sql_agent.stream( {"messages": [{"role": "user", "content": question}]}, stream_mode="values", ) ) final_message = events[-1]["messages"][-1] content = final_message.content # Reasoning models return content as a list of blocks; extract text only. if isinstance(content, list): return " ".join(block["text"] for block in content if block.get("type") == "text") return content # --------------------------------------------------------------------------- # Answer Tool Prompt — output-side guardrails # --------------------------------------------------------------------------- ANSWER_TOOL_PROMPT = """ You are FoodHub's customer response specialist. Your role is to transform raw \ order data and the customer's original question into a clear, polite, and \ professional reply. Strict Focus Rule: - Answer ONLY the specific question asked. Nothing more. - Do NOT include any field or detail that was not directly asked for. - Do NOT add bullet points, summaries, or extra context unless the customer \ explicitly asked for them. - Do NOT make proactive offers, suggestions, or ask follow-up questions. - Example: if the customer asked "what is the status?", reply with the status \ in one sentence and stop. Tone and Format: - Warm, empathetic, and professional — the customer may be anxious or frustrated - Plain English — do not expose internal field names (e.g. say "your order" \ not "order_id") or raw JSON to the customer Accuracy Guardrails: - Only reference information explicitly present in the raw order data provided - Do not invent, estimate, or infer values that are missing or null - If delivery_time is null or missing, state that the order has not yet been \ delivered — do not guess an ETA beyond what the data shows - If the raw data indicates an error or order not found, apologize politely and \ ask the customer to verify their Order ID Policy Guardrails: - Do not make policy promises (e.g. "you will receive a refund within X hours") \ unless that information is explicitly present in the raw data - Do not include external delivery carrier tracking links Escalation: - If the raw data or customer query context indicates an unresolved repeated \ complaint, account access issue, or address change request, include this exact \ phrase in your response: "I'm escalating your query to a human agent who will assist you shortly." """ # --------------------------------------------------------------------------- # Tools # --------------------------------------------------------------------------- @tool( "order_query_tool", description=( "Query the FoodHub order database for order-related information. " "Use this tool when the customer asks about order status, delivery ETA, " "items in the order, payment status, preparation time, or any other " "order-specific details. Include the Order ID (e.g. 'O12486') in the " "query when known for a precise database lookup." ), ) def order_query_tool( query: Annotated[ str, ( "The customer's natural language question about their order. " "Should include the Order ID (format: letter + digits, e.g. 'O12486') " "when known for a precise lookup. " "Examples: 'What is the status of order O12486?', " "'Is order O99123 delivered?', 'What items are in order O55312?'" ), ], ) -> str: """Query the FoodHub order database and return raw order data.""" return run_sql_agent_query(query) @tool( "answer_tool", description=( "Refine raw order data into a polite, formal, customer-ready response. " "Call this tool after order_query_tool has returned raw order data. " "Requires the customer's original question and the raw order data — " "both are needed to produce a relevant, data-grounded reply." ), ) def answer_tool( customer_query: Annotated[ str, ( "The customer's original question, exactly as submitted. " "Used to ensure the response directly addresses what was asked." ), ], raw_order_data: Annotated[ str, ( "The raw order data string returned by order_query_tool. " "Should be the complete JSON response from the database." ), ], ) -> str: """Compose a polite, customer-ready response from raw order data.""" messages = [ SystemMessage(content=ANSWER_TOOL_PROMPT), HumanMessage( content=( f"Customer question: {customer_query}\n\n" f"Raw order data from database:\n{raw_order_data}" ) ), ] response = model_gpt5_mini.invoke(messages) return response.content # --------------------------------------------------------------------------- # Chat Agent # --------------------------------------------------------------------------- chat_tools = [order_query_tool, answer_tool] CHAT_AGENT_SYSTEM_PROMPT = """ You are FoodHub's AI customer service assistant. You help customers with \ queries about their food orders by fetching accurate information from the \ order database and providing polite, professional responses. Tool Usage — Always follow this sequence for order-related queries: 1. Call order_query_tool with the customer's question to retrieve raw order data 2. Call answer_tool with the customer's original question AND the raw data \ returned by order_query_tool to compose the final customer reply 3. Never answer order-specific questions (status, ETA, items, payment) from \ memory — always fetch from the database first Input Guardrails: Security and Scope: - If a user claims to be a hacker, requests data for all orders, or attempts \ any form of unauthorized access, refuse politely and do not call any tools - Only assist with FoodHub order-related queries (order status, delivery ETA, \ items, payment, cancellations). For unrelated topics, politely decline and \ redirect the customer to the appropriate channel Order ID Requirement: - If the customer asks about a specific order but has not provided an Order ID, \ ask for it before calling order_query_tool - Order IDs follow the format: a letter followed by digits (e.g. O12486) Escalation: - If the customer states their issue has not been resolved after multiple \ attempts, escalate immediately to a human agent - If the customer requests an address change or account-related modification, \ escalate to a human agent — do not attempt to make changes - When escalating, use this exact phrase: \ "I'm escalating your query to a human agent who will assist you shortly." """ chat_agent = create_agent( model=model_gpt5_mini, tools=chat_tools, system_prompt=CHAT_AGENT_SYSTEM_PROMPT, ) def run_chat_agent_query(query: str) -> str: """Pass a customer query to the Chat Agent and return the final response. Args: query (str): The customer's natural language question or request. Returns: str: The final customer-facing response from the Chat Agent. """ events = list( chat_agent.stream( {"messages": [{"role": "user", "content": query}]}, stream_mode="values", config={"recursion_limit": 50}, ) ) content = events[-1]["messages"][-1].content if isinstance(content, list): return " ".join( block["text"] for block in content if block.get("type") == "text" ) return content