Spaces:
Sleeping
Sleeping
File size: 11,894 Bytes
c622639 a80d5f4 c622639 a80d5f4 c622639 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 | """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 |