Spaces:
Runtime error
Runtime error
Upload 16 files
Browse files- src/__init__.py +0 -0
- src/agents/__init__.py +0 -0
- src/agents/graph.py +129 -0
- src/agents/nodes.py +234 -0
- src/agents/prompts.py +156 -0
- src/config.py +28 -0
- src/db/__init__.py +3 -0
- src/db/database.py +107 -0
- src/models.py +21 -0
- src/state.py +11 -0
- src/tools/__init__.py +4 -0
- src/tools/invoice.py +146 -0
- src/tools/music_catalog.py +246 -0
- src/ui/__init__.py +0 -0
- src/ui/app.py +262 -0
- src/ui/styles.py +46 -0
src/__init__.py
ADDED
|
File without changes
|
src/agents/__init__.py
ADDED
|
File without changes
|
src/agents/graph.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Graph builder module. Assembles the complete multi-agent LangGraph workflow."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from langchain_openai import ChatOpenAI
|
| 5 |
+
from langgraph.graph import StateGraph, START, END
|
| 6 |
+
from langgraph.prebuilt import ToolNode, create_react_agent
|
| 7 |
+
from langgraph.checkpoint.memory import MemorySaver
|
| 8 |
+
from langgraph.store.memory import InMemoryStore
|
| 9 |
+
|
| 10 |
+
from src.state import State
|
| 11 |
+
from src.tools import music_tools, invoice_tools
|
| 12 |
+
from src.agents.prompts import INVOICE_SUBAGENT_PROMPT, SUPERVISOR_PROMPT
|
| 13 |
+
from src.agents.nodes import (
|
| 14 |
+
create_music_assistant_node,
|
| 15 |
+
should_continue,
|
| 16 |
+
should_interrupt,
|
| 17 |
+
create_verify_info_node,
|
| 18 |
+
human_input,
|
| 19 |
+
load_memory,
|
| 20 |
+
create_memory_node,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def build_graph(
|
| 27 |
+
model_name: str = "gpt-4o-mini",
|
| 28 |
+
temperature: float = 0,
|
| 29 |
+
openai_api_key: str = None,
|
| 30 |
+
openai_api_base: str = None,
|
| 31 |
+
):
|
| 32 |
+
llm_kwargs = {
|
| 33 |
+
"model": model_name,
|
| 34 |
+
"temperature": temperature,
|
| 35 |
+
}
|
| 36 |
+
if openai_api_key:
|
| 37 |
+
llm_kwargs["api_key"] = openai_api_key
|
| 38 |
+
if openai_api_base:
|
| 39 |
+
llm_kwargs["base_url"] = openai_api_base
|
| 40 |
+
|
| 41 |
+
llm = ChatOpenAI(**llm_kwargs)
|
| 42 |
+
logger.info(f"LLM initialized: {model_name}, temperature={temperature}")
|
| 43 |
+
|
| 44 |
+
# NOTE: Both stores are in-memory only — all data is lost on restart.
|
| 45 |
+
# For production, replace with SqliteSaver / persistent store.
|
| 46 |
+
in_memory_store = InMemoryStore()
|
| 47 |
+
checkpointer = MemorySaver()
|
| 48 |
+
|
| 49 |
+
# Music Catalog Sub-Agent (hand-built ReAct)
|
| 50 |
+
music_assistant_fn = create_music_assistant_node(llm, music_tools)
|
| 51 |
+
music_tool_node = ToolNode(music_tools)
|
| 52 |
+
|
| 53 |
+
music_workflow = StateGraph(State)
|
| 54 |
+
music_workflow.add_node("music_assistant", music_assistant_fn)
|
| 55 |
+
music_workflow.add_node("music_tool_node", music_tool_node)
|
| 56 |
+
music_workflow.add_edge(START, "music_assistant")
|
| 57 |
+
music_workflow.add_conditional_edges(
|
| 58 |
+
"music_assistant",
|
| 59 |
+
should_continue,
|
| 60 |
+
{"continue": "music_tool_node", "end": END},
|
| 61 |
+
)
|
| 62 |
+
music_workflow.add_edge("music_tool_node", "music_assistant")
|
| 63 |
+
|
| 64 |
+
music_catalog_subagent = music_workflow.compile(
|
| 65 |
+
name="music_catalog_subagent",
|
| 66 |
+
checkpointer=checkpointer,
|
| 67 |
+
store=in_memory_store,
|
| 68 |
+
)
|
| 69 |
+
logger.info("Music catalog sub-agent compiled.")
|
| 70 |
+
|
| 71 |
+
# Invoice Information Sub-Agent (pre-built ReAct)
|
| 72 |
+
invoice_information_subagent = create_react_agent(
|
| 73 |
+
llm,
|
| 74 |
+
tools=invoice_tools,
|
| 75 |
+
name="invoice_information_subagent",
|
| 76 |
+
prompt=INVOICE_SUBAGENT_PROMPT,
|
| 77 |
+
state_schema=State,
|
| 78 |
+
checkpointer=checkpointer,
|
| 79 |
+
store=in_memory_store,
|
| 80 |
+
)
|
| 81 |
+
logger.info("Invoice information sub-agent compiled.")
|
| 82 |
+
|
| 83 |
+
# Supervisor
|
| 84 |
+
from langgraph_supervisor import create_supervisor
|
| 85 |
+
|
| 86 |
+
supervisor_workflow = create_supervisor(
|
| 87 |
+
agents=[invoice_information_subagent, music_catalog_subagent],
|
| 88 |
+
output_mode="last_message",
|
| 89 |
+
model=llm,
|
| 90 |
+
prompt=SUPERVISOR_PROMPT,
|
| 91 |
+
state_schema=State,
|
| 92 |
+
)
|
| 93 |
+
supervisor_prebuilt = supervisor_workflow.compile(
|
| 94 |
+
name="supervisor",
|
| 95 |
+
checkpointer=checkpointer,
|
| 96 |
+
store=in_memory_store,
|
| 97 |
+
)
|
| 98 |
+
logger.info("Supervisor compiled.")
|
| 99 |
+
|
| 100 |
+
# Final Multi-Agent Graph
|
| 101 |
+
verify_info_fn = create_verify_info_node(llm)
|
| 102 |
+
create_memory_fn = create_memory_node(llm)
|
| 103 |
+
|
| 104 |
+
multi_agent = StateGraph(State)
|
| 105 |
+
multi_agent.add_node("verify_info", verify_info_fn)
|
| 106 |
+
multi_agent.add_node("human_input", human_input)
|
| 107 |
+
multi_agent.add_node("load_memory", load_memory)
|
| 108 |
+
multi_agent.add_node("supervisor", supervisor_prebuilt)
|
| 109 |
+
multi_agent.add_node("create_memory", create_memory_fn)
|
| 110 |
+
|
| 111 |
+
multi_agent.add_edge(START, "verify_info")
|
| 112 |
+
multi_agent.add_conditional_edges(
|
| 113 |
+
"verify_info",
|
| 114 |
+
should_interrupt,
|
| 115 |
+
{"continue": "load_memory", "interrupt": "human_input"},
|
| 116 |
+
)
|
| 117 |
+
multi_agent.add_edge("human_input", "verify_info")
|
| 118 |
+
multi_agent.add_edge("load_memory", "supervisor")
|
| 119 |
+
multi_agent.add_edge("supervisor", "create_memory")
|
| 120 |
+
multi_agent.add_edge("create_memory", END)
|
| 121 |
+
|
| 122 |
+
compiled_graph = multi_agent.compile(
|
| 123 |
+
name="multi_agent_final",
|
| 124 |
+
checkpointer=checkpointer,
|
| 125 |
+
store=in_memory_store,
|
| 126 |
+
)
|
| 127 |
+
logger.info("Final multi-agent graph compiled successfully.")
|
| 128 |
+
|
| 129 |
+
return compiled_graph, checkpointer, in_memory_store
|
src/agents/nodes.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Node functions for the multi-agent graph."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
from langchain_core.messages import SystemMessage, HumanMessage
|
| 7 |
+
from langchain_core.runnables import RunnableConfig
|
| 8 |
+
from langgraph.store.base import BaseStore
|
| 9 |
+
from langgraph.types import interrupt
|
| 10 |
+
|
| 11 |
+
from src.state import State
|
| 12 |
+
from src.models import UserInput, UserProfile
|
| 13 |
+
from src.agents.prompts import (
|
| 14 |
+
generate_music_assistant_prompt,
|
| 15 |
+
STRUCTURED_EXTRACTION_PROMPT,
|
| 16 |
+
VERIFICATION_PROMPT,
|
| 17 |
+
CREATE_MEMORY_PROMPT,
|
| 18 |
+
)
|
| 19 |
+
from src.db.database import get_engine, normalize_phone
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def get_customer_id_from_identifier(identifier: str) -> Optional[int]:
|
| 25 |
+
if not identifier or not identifier.strip():
|
| 26 |
+
return None
|
| 27 |
+
|
| 28 |
+
identifier = identifier.strip()
|
| 29 |
+
engine = get_engine()
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
from sqlalchemy import text
|
| 33 |
+
|
| 34 |
+
if "@" in identifier:
|
| 35 |
+
with engine.connect() as conn:
|
| 36 |
+
result = conn.execute(
|
| 37 |
+
text("SELECT CustomerId FROM Customer WHERE LOWER(Email) = LOWER(:email)"),
|
| 38 |
+
{"email": identifier},
|
| 39 |
+
)
|
| 40 |
+
row = result.fetchone()
|
| 41 |
+
if row:
|
| 42 |
+
return int(row[0])
|
| 43 |
+
|
| 44 |
+
if identifier.isdigit():
|
| 45 |
+
with engine.connect() as conn:
|
| 46 |
+
result = conn.execute(
|
| 47 |
+
text("SELECT CustomerId FROM Customer WHERE CustomerId = :cid"),
|
| 48 |
+
{"cid": int(identifier)},
|
| 49 |
+
)
|
| 50 |
+
row = result.fetchone()
|
| 51 |
+
if row:
|
| 52 |
+
return int(row[0])
|
| 53 |
+
|
| 54 |
+
normalized_input = normalize_phone(identifier)
|
| 55 |
+
if normalized_input and len(normalized_input) >= 5:
|
| 56 |
+
with engine.connect() as conn:
|
| 57 |
+
result = conn.execute(text("SELECT CustomerId, Phone FROM Customer WHERE Phone IS NOT NULL"))
|
| 58 |
+
for row in result:
|
| 59 |
+
db_phone_normalized = normalize_phone(str(row[1]))
|
| 60 |
+
if db_phone_normalized == normalized_input:
|
| 61 |
+
return int(row[0])
|
| 62 |
+
|
| 63 |
+
except Exception as e:
|
| 64 |
+
logger.error(f"Error looking up customer by identifier '{identifier}': {e}")
|
| 65 |
+
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def format_user_memory(user_data: dict) -> str:
|
| 70 |
+
try:
|
| 71 |
+
profile = user_data.get("memory")
|
| 72 |
+
if profile and hasattr(profile, "music_preferences") and profile.music_preferences:
|
| 73 |
+
return f"Music Preferences: {', '.join(profile.music_preferences)}"
|
| 74 |
+
except Exception as e:
|
| 75 |
+
logger.error(f"Error formatting user memory: {e}")
|
| 76 |
+
return ""
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def create_music_assistant_node(llm, music_tools):
|
| 80 |
+
llm_with_tools = llm.bind_tools(music_tools)
|
| 81 |
+
|
| 82 |
+
def music_assistant(state: State, config: RunnableConfig):
|
| 83 |
+
memory = state.get("loaded_memory", "None") or "None"
|
| 84 |
+
prompt = generate_music_assistant_prompt(memory)
|
| 85 |
+
|
| 86 |
+
messages = [SystemMessage(content=prompt)]
|
| 87 |
+
if state.get("customer_id"):
|
| 88 |
+
messages.append(
|
| 89 |
+
SystemMessage(content=f"The current verified customer ID is: {state['customer_id']}")
|
| 90 |
+
)
|
| 91 |
+
messages.extend(state["messages"])
|
| 92 |
+
|
| 93 |
+
logger.info(f"Music assistant invoked with {len(state['messages'])} conversation messages")
|
| 94 |
+
response = llm_with_tools.invoke(messages)
|
| 95 |
+
return {"messages": [response]}
|
| 96 |
+
|
| 97 |
+
return music_assistant
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def should_continue(state: State, config: RunnableConfig) -> str:
|
| 101 |
+
messages = state["messages"]
|
| 102 |
+
last_message = messages[-1]
|
| 103 |
+
if not last_message.tool_calls:
|
| 104 |
+
return "end"
|
| 105 |
+
return "continue"
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def should_interrupt(state: State, config: RunnableConfig) -> str:
|
| 109 |
+
if state.get("customer_id") is not None:
|
| 110 |
+
return "continue"
|
| 111 |
+
return "interrupt"
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def create_verify_info_node(llm):
|
| 115 |
+
structured_llm = llm.with_structured_output(schema=UserInput)
|
| 116 |
+
|
| 117 |
+
def verify_info(state: State, config: RunnableConfig):
|
| 118 |
+
if state.get("customer_id") is not None:
|
| 119 |
+
logger.info(f"Customer already verified: {state['customer_id']}")
|
| 120 |
+
return {}
|
| 121 |
+
|
| 122 |
+
user_input = state["messages"][-1]
|
| 123 |
+
logger.info(f"Verification attempt with message: {getattr(user_input, 'content', '')[:100]}")
|
| 124 |
+
|
| 125 |
+
try:
|
| 126 |
+
parsed_info = structured_llm.invoke(
|
| 127 |
+
[SystemMessage(content=STRUCTURED_EXTRACTION_PROMPT)] + [user_input]
|
| 128 |
+
)
|
| 129 |
+
identifier = parsed_info.identifier
|
| 130 |
+
logger.info(f"Extracted identifier: '{identifier}'")
|
| 131 |
+
except Exception as e:
|
| 132 |
+
logger.error(f"Error parsing user input for verification: {e}")
|
| 133 |
+
identifier = ""
|
| 134 |
+
|
| 135 |
+
customer_id = None
|
| 136 |
+
if identifier:
|
| 137 |
+
customer_id = get_customer_id_from_identifier(identifier)
|
| 138 |
+
logger.info(f"DB lookup result: customer_id={customer_id}")
|
| 139 |
+
|
| 140 |
+
if customer_id is not None:
|
| 141 |
+
intent_message = SystemMessage(
|
| 142 |
+
content=(
|
| 143 |
+
f"Customer verified successfully. "
|
| 144 |
+
f"The verified customer_id is {customer_id}. "
|
| 145 |
+
f"Use this customer_id for all invoice and purchase lookups."
|
| 146 |
+
)
|
| 147 |
+
)
|
| 148 |
+
return {
|
| 149 |
+
"customer_id": str(customer_id),
|
| 150 |
+
"messages": [intent_message],
|
| 151 |
+
}
|
| 152 |
+
else:
|
| 153 |
+
response = llm.invoke(
|
| 154 |
+
[SystemMessage(content=VERIFICATION_PROMPT)] + state["messages"]
|
| 155 |
+
)
|
| 156 |
+
return {"messages": [response]}
|
| 157 |
+
|
| 158 |
+
return verify_info
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def human_input(state: State, config: RunnableConfig):
|
| 162 |
+
user_input = interrupt("Please provide input.")
|
| 163 |
+
return {"messages": [HumanMessage(content=user_input)]}
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def load_memory(state: State, config: RunnableConfig, store: BaseStore):
|
| 167 |
+
user_id = str(state.get("customer_id", ""))
|
| 168 |
+
if not user_id:
|
| 169 |
+
return {"loaded_memory": ""}
|
| 170 |
+
|
| 171 |
+
namespace = ("memory_profile", user_id)
|
| 172 |
+
try:
|
| 173 |
+
existing_memory = store.get(namespace, "user_memory")
|
| 174 |
+
if existing_memory and existing_memory.value:
|
| 175 |
+
formatted = format_user_memory(existing_memory.value)
|
| 176 |
+
logger.info(f"Loaded memory for customer {user_id}: {formatted}")
|
| 177 |
+
return {"loaded_memory": formatted}
|
| 178 |
+
except Exception as e:
|
| 179 |
+
logger.error(f"Error loading memory for user {user_id}: {e}")
|
| 180 |
+
|
| 181 |
+
return {"loaded_memory": ""}
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def create_memory_node(llm):
|
| 185 |
+
def create_memory(state: State, config: RunnableConfig, store: BaseStore):
|
| 186 |
+
user_id = str(state.get("customer_id", ""))
|
| 187 |
+
if not user_id:
|
| 188 |
+
return {}
|
| 189 |
+
|
| 190 |
+
namespace = ("memory_profile", user_id)
|
| 191 |
+
|
| 192 |
+
try:
|
| 193 |
+
existing_preferences = []
|
| 194 |
+
existing_memory = store.get(namespace, "user_memory")
|
| 195 |
+
formatted_memory = ""
|
| 196 |
+
if existing_memory and existing_memory.value:
|
| 197 |
+
mem_dict = existing_memory.value
|
| 198 |
+
profile = mem_dict.get("memory")
|
| 199 |
+
if profile and hasattr(profile, "music_preferences"):
|
| 200 |
+
existing_preferences = list(profile.music_preferences or [])
|
| 201 |
+
formatted_memory = f"Music Preferences: {', '.join(existing_preferences)}"
|
| 202 |
+
|
| 203 |
+
recent_messages = state["messages"][-10:]
|
| 204 |
+
conversation_summary = "\n".join(
|
| 205 |
+
f"{getattr(msg, 'type', 'unknown')}: {getattr(msg, 'content', '')}"
|
| 206 |
+
for msg in recent_messages
|
| 207 |
+
if getattr(msg, "content", "")
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
formatted_prompt = CREATE_MEMORY_PROMPT.format(
|
| 211 |
+
conversation=conversation_summary,
|
| 212 |
+
memory_profile=formatted_memory or "Empty, no existing profile",
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
updated_memory = llm.with_structured_output(UserProfile).invoke(
|
| 216 |
+
[SystemMessage(content=formatted_prompt)]
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
new_prefs = updated_memory.music_preferences or []
|
| 220 |
+
if not new_prefs and existing_preferences:
|
| 221 |
+
logger.info(f"Memory unchanged for customer {user_id} (preserving existing preferences)")
|
| 222 |
+
return {}
|
| 223 |
+
|
| 224 |
+
merged_prefs = list(set(existing_preferences + new_prefs))
|
| 225 |
+
updated_memory.music_preferences = merged_prefs
|
| 226 |
+
updated_memory.customer_id = user_id
|
| 227 |
+
|
| 228 |
+
store.put(namespace, "user_memory", {"memory": updated_memory})
|
| 229 |
+
logger.info(f"Memory updated for customer {user_id}: {merged_prefs}")
|
| 230 |
+
|
| 231 |
+
except Exception as e:
|
| 232 |
+
logger.error(f"Error creating/updating memory for user {user_id}: {e}")
|
| 233 |
+
|
| 234 |
+
return create_memory
|
src/agents/prompts.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""System prompts for all agents in the multi-agent system."""
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def generate_music_assistant_prompt(memory: str = "None") -> str:
|
| 5 |
+
return f"""You are the Music Catalog Assistant for a digital music store.
|
| 6 |
+
You help customers discover and learn about music in the catalog.
|
| 7 |
+
|
| 8 |
+
=== GROUNDING RULES (CRITICAL) ===
|
| 9 |
+
1. ONLY provide information that is returned by your tools. DO NOT infer, assume, or extrapolate.
|
| 10 |
+
2. If a tool returns "No albums found", "No tracks found", or similar, tell the customer exactly that.
|
| 11 |
+
Say: "I could not find that in our catalog."
|
| 12 |
+
3. NEVER fabricate or guess album names, track names, artist names, prices, durations, or any other data.
|
| 13 |
+
4. If results are truncated (the tool shows a sample), mention the total count and that more tracks exist.
|
| 14 |
+
5. If you are unsure, say so. Do not make up information.
|
| 15 |
+
6. Always quote exact numbers from tool results — never round, estimate, or approximate.
|
| 16 |
+
7. If the customer asks about data outside your tools' scope (playlists, employee info, billing), say:
|
| 17 |
+
"I specialize in music catalog queries. Let me pass this to the right team."
|
| 18 |
+
|
| 19 |
+
=== TOOLS AVAILABLE ===
|
| 20 |
+
1. get_albums_by_artist(artist): Search albums by artist name (fuzzy match). Returns AlbumId, AlbumTitle, ArtistName.
|
| 21 |
+
2. get_tracks_by_artist(artist): Search tracks by artist (sample of up to 20 with full details).
|
| 22 |
+
3. get_songs_by_genre(genre): Get one representative song per artist in a genre (up to 10, with total count).
|
| 23 |
+
4. check_for_songs(song_title): Search for a specific song by title (up to 10 matches with full details).
|
| 24 |
+
5. get_track_details(track_id): Get ALL details for a specific track by TrackId.
|
| 25 |
+
|
| 26 |
+
All track tools return: TrackId, SongName, ArtistName, AlbumTitle, GenreName, Composer,
|
| 27 |
+
Milliseconds, DurationMinutes, Bytes, UnitPrice, and MediaType.
|
| 28 |
+
Present the relevant details to the customer. Never say information is "not available"
|
| 29 |
+
when it is present in the tool results.
|
| 30 |
+
|
| 31 |
+
=== SEARCH GUIDELINES ===
|
| 32 |
+
1. Always call the appropriate tool before answering. Do not answer from memory.
|
| 33 |
+
2. If exact matches are not found, try alternative spellings or partial names.
|
| 34 |
+
3. When providing song lists, include the artist name and album for each song.
|
| 35 |
+
|
| 36 |
+
=== RESPONSE FORMAT ===
|
| 37 |
+
Keep responses concise and well organized. Use clear formatting for lists.
|
| 38 |
+
Always be helpful and friendly.
|
| 39 |
+
|
| 40 |
+
Prior saved user preferences: {memory}
|
| 41 |
+
|
| 42 |
+
Message history is also attached."""
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
INVOICE_SUBAGENT_PROMPT = """You are the Invoice Information Assistant for a digital music store.
|
| 46 |
+
You retrieve and present invoice and purchase information for verified customers.
|
| 47 |
+
|
| 48 |
+
=== GROUNDING RULES (CRITICAL) ===
|
| 49 |
+
1. ONLY provide information returned by your tools. NEVER fabricate invoice data.
|
| 50 |
+
2. If a tool returns an error or empty result, say: "I could not retrieve that invoice information."
|
| 51 |
+
3. NEVER guess invoice amounts, dates, track lists, or employee names.
|
| 52 |
+
4. Always quote exact numbers (totals, prices, dates) from tool results — never round or estimate.
|
| 53 |
+
5. DO NOT infer, assume, or extrapolate beyond what tools return.
|
| 54 |
+
|
| 55 |
+
=== CUSTOMER ID ===
|
| 56 |
+
CRITICAL: The verified customer ID will be provided in a system message in the conversation.
|
| 57 |
+
Look for the message that says "The verified customer_id is X".
|
| 58 |
+
Use ONLY that customer_id for ALL tool calls. Do NOT extract or guess customer IDs from other parts of the conversation.
|
| 59 |
+
|
| 60 |
+
=== TOOLS AVAILABLE ===
|
| 61 |
+
1. get_invoices_by_customer_sorted_by_date(customer_id): All invoices for a customer, most recent first.
|
| 62 |
+
Returns: InvoiceId, InvoiceDate, BillingAddress, Total.
|
| 63 |
+
2. get_invoice_line_items_sorted_by_price(customer_id): All purchased tracks across all invoices,
|
| 64 |
+
sorted by unit price (highest first). Each row is ONE purchased track (not a full invoice).
|
| 65 |
+
Returns: InvoiceId, InvoiceDate, InvoiceTotal, TrackName, UnitPrice, Quantity.
|
| 66 |
+
3. get_employee_by_invoice_and_customer(invoice_id, customer_id): Support rep info for an invoice.
|
| 67 |
+
4. get_invoice_line_items(invoice_id, customer_id): Detailed track info for a specific invoice.
|
| 68 |
+
|
| 69 |
+
=== COMMON QUERIES ===
|
| 70 |
+
- "What was my most recent purchase?" -> Use get_invoices_by_customer_sorted_by_date, then get_invoice_line_items for the first invoice.
|
| 71 |
+
- "How much was my last invoice?" -> Use get_invoices_by_customer_sorted_by_date, report the Total from the first result.
|
| 72 |
+
- "What's the most expensive track I bought?" -> Use get_invoice_line_items_sorted_by_price, report the first result.
|
| 73 |
+
- "Who helped me?" -> Use get_invoices_by_customer_sorted_by_date to find the invoice, then get_employee_by_invoice_and_customer.
|
| 74 |
+
|
| 75 |
+
=== SCOPE ===
|
| 76 |
+
You handle ONLY invoice and purchase queries. If the query is about music catalog, respond:
|
| 77 |
+
"I specialize in invoice queries. Let me pass this to the right team."
|
| 78 |
+
|
| 79 |
+
You may have additional context below:"""
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
SUPERVISOR_PROMPT = """You are the supervisor for a digital music store customer support team.
|
| 83 |
+
Your job is to route customer queries to the right sub-agent and combine their responses.
|
| 84 |
+
|
| 85 |
+
=== YOUR TEAM ===
|
| 86 |
+
1. music_catalog_subagent: Handles music catalog queries (albums, tracks, songs, artists, genres).
|
| 87 |
+
Has access to the customer's saved music preferences.
|
| 88 |
+
2. invoice_information_subagent: Handles invoice, purchase, and billing queries.
|
| 89 |
+
Needs the customer_id (already verified and available in conversation context).
|
| 90 |
+
|
| 91 |
+
=== ROUTING RULES ===
|
| 92 |
+
1. Music/catalog questions -> route to music_catalog_subagent
|
| 93 |
+
2. Invoice/purchase/billing questions -> route to invoice_information_subagent
|
| 94 |
+
3. Mixed questions (both music AND invoice) -> route to invoice_information_subagent FIRST, then music_catalog_subagent SECOND
|
| 95 |
+
4. Off-topic questions (weather, general knowledge, unrelated topics) -> respond DIRECTLY:
|
| 96 |
+
"I can only help with music store inquiries such as looking up songs, albums, artists, or your purchase history."
|
| 97 |
+
Do NOT route off-topic queries to any sub-agent.
|
| 98 |
+
|
| 99 |
+
=== RESPONSE RULES ===
|
| 100 |
+
1. After all sub-agents have responded, combine ALL their answers into a single coherent response.
|
| 101 |
+
2. Do NOT drop any sub-agent's answer. Both parts of a mixed query must appear in the final response.
|
| 102 |
+
3. If a sub-agent reports that information was not found, include that in your response honestly.
|
| 103 |
+
4. When routing to invoice_information_subagent, ensure the customer_id from the conversation is available in context.
|
| 104 |
+
5. Keep your final combined response clear and well-organized.
|
| 105 |
+
6. NEVER add information that was not in the sub-agents' responses."""
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
STRUCTURED_EXTRACTION_PROMPT = """You are a customer service system that extracts customer identifiers from messages.
|
| 109 |
+
|
| 110 |
+
Your task: Extract exactly ONE identifier from the user's message. The identifier can be:
|
| 111 |
+
- A customer ID (a number, e.g., "1", "42")
|
| 112 |
+
- An email address (contains @, e.g., "user@example.com")
|
| 113 |
+
- A phone number (starts with + or contains formatted digits, e.g., "+55 (12) 3923-5555")
|
| 114 |
+
|
| 115 |
+
Rules:
|
| 116 |
+
1. Extract ONLY the identifier. Do not extract names, questions, or other content.
|
| 117 |
+
2. If the message contains multiple possible identifiers, prefer: customer ID > email > phone.
|
| 118 |
+
3. If no identifier is present in the message, return an empty string for the identifier field.
|
| 119 |
+
4. Do not fabricate identifiers. Only extract what is explicitly stated."""
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
VERIFICATION_PROMPT = """You are a music store support agent. Your current task is to verify the customer's identity before you can help them.
|
| 123 |
+
|
| 124 |
+
To verify identity, the customer must provide ONE of:
|
| 125 |
+
- Customer ID (a number)
|
| 126 |
+
- Email address
|
| 127 |
+
- Phone number
|
| 128 |
+
|
| 129 |
+
Rules:
|
| 130 |
+
1. If the customer has NOT provided any identifier, ask politely:
|
| 131 |
+
"To help you with your account, I'll need to verify your identity. Could you please provide your Customer ID, email address, or phone number?"
|
| 132 |
+
2. If the customer provided an identifier but it was NOT found in our system, say:
|
| 133 |
+
"I wasn't able to find an account with that information. Could you please double-check and try again? You can provide your Customer ID, email, or phone number."
|
| 134 |
+
3. Be friendly and concise. Do not ask for more than one identifier at a time.
|
| 135 |
+
4. If the customer is asking a general music catalog question (about songs, albums, artists) without needing account access, you may note that they don't need to verify for music browsing questions."""
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
CREATE_MEMORY_PROMPT = """You are analyzing a conversation to update a customer's music preference profile.
|
| 139 |
+
|
| 140 |
+
=== RULES ===
|
| 141 |
+
1. Only save preferences the customer EXPLICITLY stated they like or enjoy.
|
| 142 |
+
Examples of explicit preferences: "I love rock music", "I like AC/DC", "Jazz is my favorite"
|
| 143 |
+
2. Do NOT save preferences from questions alone.
|
| 144 |
+
Examples that are NOT preferences: "Do you have rock music?", "What songs are in Jazz?"
|
| 145 |
+
3. If no new preferences were expressed, keep the existing preferences UNCHANGED.
|
| 146 |
+
CRITICAL: Do not return an empty list if existing preferences exist. Preserve them.
|
| 147 |
+
4. Include artists, genres, or specific albums the customer expressed interest in.
|
| 148 |
+
5. Merge new preferences with existing ones. Never remove existing preferences.
|
| 149 |
+
|
| 150 |
+
=== CONVERSATION ===
|
| 151 |
+
{conversation}
|
| 152 |
+
|
| 153 |
+
=== EXISTING MEMORY PROFILE ===
|
| 154 |
+
{memory_profile}
|
| 155 |
+
|
| 156 |
+
Respond with the updated profile object. If nothing new was expressed, return the existing profile as-is."""
|
src/config.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
logging.basicConfig(
|
| 8 |
+
level=logging.INFO,
|
| 9 |
+
format="%(asctime)s | %(name)s | %(levelname)s | %(message)s",
|
| 10 |
+
handlers=[logging.StreamHandler()],
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Settings:
|
| 15 |
+
openai_api_key: str = os.getenv("OPENAI_API_KEY", "")
|
| 16 |
+
openai_api_base: str = os.getenv("OPENAI_API_BASE", "")
|
| 17 |
+
model_name: str = os.getenv("MODEL_NAME", "gpt-4o-mini")
|
| 18 |
+
temperature: float = float(os.getenv("TEMPERATURE", "0"))
|
| 19 |
+
port: int = int(os.getenv("PORT", "7860"))
|
| 20 |
+
app_title: str = "Music Store Assistant"
|
| 21 |
+
app_description: str = (
|
| 22 |
+
"Welcome! I can help you explore our music catalog, look up invoices, "
|
| 23 |
+
"and find your purchase history. To access your account, please provide "
|
| 24 |
+
"your Customer ID, email, or phone number."
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
settings = Settings()
|
src/db/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from src.db.database import get_engine, get_db, run_query_safe, normalize_phone, verify_database
|
| 2 |
+
|
| 3 |
+
__all__ = ["get_engine", "get_db", "run_query_safe", "normalize_phone", "verify_database"]
|
src/db/database.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
import sqlite3
|
| 5 |
+
import requests
|
| 6 |
+
import logging
|
| 7 |
+
from langchain_community.utilities.sql_database import SQLDatabase
|
| 8 |
+
from sqlalchemy import create_engine, text
|
| 9 |
+
from sqlalchemy.pool import StaticPool
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
_engine = None
|
| 14 |
+
_db = None
|
| 15 |
+
|
| 16 |
+
CHINOOK_SQL_URL = (
|
| 17 |
+
"https://raw.githubusercontent.com/lerocha/chinook-database/"
|
| 18 |
+
"master/ChinookDatabase/DataSources/Chinook_Sqlite.sql"
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
# Use project root for the cached SQL file
|
| 22 |
+
LOCAL_SQL_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "Chinook_Sqlite.sql")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _load_sql_script() -> str:
|
| 26 |
+
if os.path.isfile(LOCAL_SQL_PATH):
|
| 27 |
+
logger.info(f"Loading Chinook SQL from local file: {LOCAL_SQL_PATH}")
|
| 28 |
+
with open(LOCAL_SQL_PATH, "r", encoding="utf-8") as f:
|
| 29 |
+
return f.read()
|
| 30 |
+
logger.info("Local SQL file not found. Downloading from GitHub...")
|
| 31 |
+
response = requests.get(CHINOOK_SQL_URL, timeout=60)
|
| 32 |
+
response.raise_for_status()
|
| 33 |
+
sql_script = response.text
|
| 34 |
+
try:
|
| 35 |
+
with open(LOCAL_SQL_PATH, "w", encoding="utf-8") as f:
|
| 36 |
+
f.write(sql_script)
|
| 37 |
+
logger.info(f"Cached SQL script to {LOCAL_SQL_PATH}")
|
| 38 |
+
except Exception as e:
|
| 39 |
+
logger.warning(f"Could not cache SQL script locally: {e}")
|
| 40 |
+
return sql_script
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _create_engine():
|
| 44 |
+
sql_script = _load_sql_script()
|
| 45 |
+
connection = sqlite3.connect(":memory:", check_same_thread=False)
|
| 46 |
+
connection.executescript(sql_script)
|
| 47 |
+
logger.info("Chinook database loaded successfully into memory.")
|
| 48 |
+
return create_engine(
|
| 49 |
+
"sqlite://",
|
| 50 |
+
creator=lambda: connection,
|
| 51 |
+
poolclass=StaticPool,
|
| 52 |
+
connect_args={"check_same_thread": False},
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def get_engine():
|
| 57 |
+
global _engine
|
| 58 |
+
if _engine is None:
|
| 59 |
+
_engine = _create_engine()
|
| 60 |
+
return _engine
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def get_db() -> SQLDatabase:
|
| 64 |
+
global _db
|
| 65 |
+
if _db is None:
|
| 66 |
+
_db = SQLDatabase(get_engine())
|
| 67 |
+
return _db
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def run_query_safe(query: str, params: dict = None) -> str:
|
| 71 |
+
engine = get_engine()
|
| 72 |
+
try:
|
| 73 |
+
with engine.connect() as conn:
|
| 74 |
+
if params:
|
| 75 |
+
result = conn.execute(text(query), params)
|
| 76 |
+
else:
|
| 77 |
+
result = conn.execute(text(query))
|
| 78 |
+
rows = result.fetchall()
|
| 79 |
+
columns = result.keys()
|
| 80 |
+
if not rows:
|
| 81 |
+
return "[]"
|
| 82 |
+
results_list = [dict(zip(columns, row)) for row in rows]
|
| 83 |
+
return json.dumps(results_list, default=str)
|
| 84 |
+
except Exception as e:
|
| 85 |
+
logger.error(f"Query error: {e} | query={query} | params={params}")
|
| 86 |
+
raise
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def normalize_phone(phone: str) -> str:
|
| 90 |
+
if not phone:
|
| 91 |
+
return ""
|
| 92 |
+
phone = phone.strip()
|
| 93 |
+
if phone.startswith("+"):
|
| 94 |
+
return "+" + re.sub(r"[^\d]", "", phone[1:])
|
| 95 |
+
return re.sub(r"[^\d]", "", phone)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def verify_database() -> dict:
|
| 99 |
+
try:
|
| 100 |
+
db = get_db()
|
| 101 |
+
tables = db.get_usable_table_names()
|
| 102 |
+
result = db.run("SELECT COUNT(*) FROM Customer;")
|
| 103 |
+
logger.info(f"Database verification OK. Customer count query returned: {result}")
|
| 104 |
+
return {"status": "healthy", "tables": len(tables)}
|
| 105 |
+
except Exception as e:
|
| 106 |
+
logger.error(f"Database verification failed: {e}")
|
| 107 |
+
return {"status": "unhealthy", "error": str(e)}
|
src/models.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
from pydantic import BaseModel, Field
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class UserInput(BaseModel):
|
| 6 |
+
identifier: str = Field(
|
| 7 |
+
default="",
|
| 8 |
+
description=(
|
| 9 |
+
"Identifier: can be a customer ID (numeric), "
|
| 10 |
+
"email address (contains @), or phone number (starts with + or contains digits). "
|
| 11 |
+
"Return empty string if no identifier is found in the message."
|
| 12 |
+
),
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class UserProfile(BaseModel):
|
| 17 |
+
customer_id: str = Field(description="The customer ID of the customer")
|
| 18 |
+
music_preferences: List[str] = Field(
|
| 19 |
+
default_factory=list,
|
| 20 |
+
description="The music preferences of the customer (artists, genres, albums they like)",
|
| 21 |
+
)
|
src/state.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Annotated, Optional
|
| 2 |
+
from typing_extensions import TypedDict
|
| 3 |
+
from langgraph.graph.message import AnyMessage, add_messages
|
| 4 |
+
from langgraph.managed.is_last_step import RemainingSteps
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class State(TypedDict):
|
| 8 |
+
customer_id: Optional[str]
|
| 9 |
+
messages: Annotated[list[AnyMessage], add_messages]
|
| 10 |
+
loaded_memory: str
|
| 11 |
+
remaining_steps: RemainingSteps
|
src/tools/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from src.tools.music_catalog import music_tools
|
| 2 |
+
from src.tools.invoice import invoice_tools
|
| 3 |
+
|
| 4 |
+
__all__ = ["music_tools", "invoice_tools"]
|
src/tools/invoice.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Invoice information tools for the multi-agent system."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from langchain_core.tools import tool
|
| 5 |
+
from src.db.database import run_query_safe
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _safe_int(value: str, label: str = "value") -> int:
|
| 11 |
+
try:
|
| 12 |
+
return int(value)
|
| 13 |
+
except (ValueError, TypeError):
|
| 14 |
+
raise ValueError(f"Invalid {label}: '{value}'. Please provide a numeric value.")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@tool
|
| 18 |
+
def get_invoices_by_customer_sorted_by_date(customer_id: str) -> str:
|
| 19 |
+
"""
|
| 20 |
+
Look up all invoices for a customer using their ID.
|
| 21 |
+
Returns invoices sorted by date (most recent first).
|
| 22 |
+
"""
|
| 23 |
+
logger.info(f"TOOL_CALL: get_invoices_by_customer_sorted_by_date | customer_id={customer_id}")
|
| 24 |
+
try:
|
| 25 |
+
result = run_query_safe(
|
| 26 |
+
"""
|
| 27 |
+
SELECT InvoiceId, CustomerId, InvoiceDate, BillingAddress, BillingCity,
|
| 28 |
+
BillingState, BillingCountry, BillingPostalCode, Total
|
| 29 |
+
FROM Invoice
|
| 30 |
+
WHERE CustomerId = :customer_id
|
| 31 |
+
ORDER BY InvoiceDate DESC;
|
| 32 |
+
""",
|
| 33 |
+
{"customer_id": _safe_int(customer_id, "customer ID")},
|
| 34 |
+
)
|
| 35 |
+
logger.info(f"TOOL_RESULT: get_invoices_by_customer_sorted_by_date | result_length={len(result)}")
|
| 36 |
+
if result == "[]":
|
| 37 |
+
return f"No invoices found for customer {customer_id}."
|
| 38 |
+
return result
|
| 39 |
+
except Exception as e:
|
| 40 |
+
logger.error(f"Error in get_invoices_by_customer_sorted_by_date: {e}")
|
| 41 |
+
return f"Error retrieving invoices for customer {customer_id}. Please try again."
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@tool
|
| 45 |
+
def get_invoice_line_items_sorted_by_price(customer_id: str) -> str:
|
| 46 |
+
"""
|
| 47 |
+
Look up all purchased line items for a customer, sorted by unit price (highest first).
|
| 48 |
+
Each row is a single purchased track (NOT a full invoice). An invoice with 5 tracks
|
| 49 |
+
will appear as 5 separate rows, each showing the track name, unit price, and quantity.
|
| 50 |
+
"""
|
| 51 |
+
logger.info(f"TOOL_CALL: get_invoice_line_items_sorted_by_price | customer_id={customer_id}")
|
| 52 |
+
try:
|
| 53 |
+
result = run_query_safe(
|
| 54 |
+
"""
|
| 55 |
+
SELECT Invoice.InvoiceId, Invoice.InvoiceDate, Invoice.Total AS InvoiceTotal,
|
| 56 |
+
Track.Name AS TrackName, InvoiceLine.UnitPrice, InvoiceLine.Quantity
|
| 57 |
+
FROM Invoice
|
| 58 |
+
JOIN InvoiceLine ON Invoice.InvoiceId = InvoiceLine.InvoiceId
|
| 59 |
+
JOIN Track ON InvoiceLine.TrackId = Track.TrackId
|
| 60 |
+
WHERE Invoice.CustomerId = :customer_id
|
| 61 |
+
ORDER BY InvoiceLine.UnitPrice DESC;
|
| 62 |
+
""",
|
| 63 |
+
{"customer_id": _safe_int(customer_id, "customer ID")},
|
| 64 |
+
)
|
| 65 |
+
logger.info(f"TOOL_RESULT: get_invoice_line_items_sorted_by_price | result_length={len(result)}")
|
| 66 |
+
if result == "[]":
|
| 67 |
+
return f"No purchase records found for customer {customer_id}."
|
| 68 |
+
return result
|
| 69 |
+
except Exception as e:
|
| 70 |
+
logger.error(f"Error in get_invoice_line_items_sorted_by_price: {e}")
|
| 71 |
+
return f"Error retrieving purchase records for customer {customer_id}. Please try again."
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@tool
|
| 75 |
+
def get_employee_by_invoice_and_customer(invoice_id: str, customer_id: str) -> str:
|
| 76 |
+
"""
|
| 77 |
+
Find the employee (support rep) associated with a specific invoice and customer.
|
| 78 |
+
Returns employee full name, title, and email.
|
| 79 |
+
"""
|
| 80 |
+
logger.info(f"TOOL_CALL: get_employee_by_invoice_and_customer | invoice_id={invoice_id}, customer_id={customer_id}")
|
| 81 |
+
try:
|
| 82 |
+
result = run_query_safe(
|
| 83 |
+
"""
|
| 84 |
+
SELECT Employee.FirstName, Employee.LastName, Employee.Title, Employee.Email
|
| 85 |
+
FROM Employee
|
| 86 |
+
JOIN Customer ON Customer.SupportRepId = Employee.EmployeeId
|
| 87 |
+
JOIN Invoice ON Invoice.CustomerId = Customer.CustomerId
|
| 88 |
+
WHERE Invoice.InvoiceId = :invoice_id AND Invoice.CustomerId = :customer_id;
|
| 89 |
+
""",
|
| 90 |
+
{"invoice_id": _safe_int(invoice_id, "invoice ID"), "customer_id": _safe_int(customer_id, "customer ID")},
|
| 91 |
+
)
|
| 92 |
+
logger.info(f"TOOL_RESULT: get_employee_by_invoice_and_customer | result_length={len(result)}")
|
| 93 |
+
if result == "[]":
|
| 94 |
+
return f"No employee found for invoice ID {invoice_id} and customer ID {customer_id}."
|
| 95 |
+
return result
|
| 96 |
+
except Exception as e:
|
| 97 |
+
logger.error(f"Error in get_employee_by_invoice_and_customer: {e}")
|
| 98 |
+
return f"Error finding employee for invoice {invoice_id}. Please try again."
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@tool
|
| 102 |
+
def get_invoice_line_items(invoice_id: str, customer_id: str) -> str:
|
| 103 |
+
"""
|
| 104 |
+
Get the detailed line items (tracks purchased) for a specific invoice.
|
| 105 |
+
Returns full track details for each purchased item.
|
| 106 |
+
"""
|
| 107 |
+
logger.info(f"TOOL_CALL: get_invoice_line_items | invoice_id={invoice_id}, customer_id={customer_id}")
|
| 108 |
+
try:
|
| 109 |
+
result = run_query_safe(
|
| 110 |
+
"""
|
| 111 |
+
SELECT Track.TrackId,
|
| 112 |
+
Track.Name AS TrackName,
|
| 113 |
+
Artist.Name AS ArtistName,
|
| 114 |
+
Album.Title AS AlbumTitle,
|
| 115 |
+
Genre.Name AS GenreName,
|
| 116 |
+
Track.Composer,
|
| 117 |
+
Track.Milliseconds,
|
| 118 |
+
ROUND(Track.Milliseconds / 60000.0, 1) AS DurationMinutes,
|
| 119 |
+
InvoiceLine.UnitPrice,
|
| 120 |
+
InvoiceLine.Quantity
|
| 121 |
+
FROM InvoiceLine
|
| 122 |
+
JOIN Invoice ON InvoiceLine.InvoiceId = Invoice.InvoiceId
|
| 123 |
+
JOIN Track ON InvoiceLine.TrackId = Track.TrackId
|
| 124 |
+
LEFT JOIN Album ON Track.AlbumId = Album.AlbumId
|
| 125 |
+
LEFT JOIN Artist ON Album.ArtistId = Artist.ArtistId
|
| 126 |
+
LEFT JOIN Genre ON Track.GenreId = Genre.GenreId
|
| 127 |
+
WHERE Invoice.InvoiceId = :invoice_id AND Invoice.CustomerId = :customer_id
|
| 128 |
+
ORDER BY Track.Name;
|
| 129 |
+
""",
|
| 130 |
+
{"invoice_id": _safe_int(invoice_id, "invoice ID"), "customer_id": _safe_int(customer_id, "customer ID")},
|
| 131 |
+
)
|
| 132 |
+
logger.info(f"TOOL_RESULT: get_invoice_line_items | result_length={len(result)}")
|
| 133 |
+
if result == "[]":
|
| 134 |
+
return f"No line items found for invoice {invoice_id} (customer {customer_id})."
|
| 135 |
+
return result
|
| 136 |
+
except Exception as e:
|
| 137 |
+
logger.error(f"Error in get_invoice_line_items: {e}")
|
| 138 |
+
return f"Error retrieving line items for invoice {invoice_id}. Please try again."
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
invoice_tools = [
|
| 142 |
+
get_invoices_by_customer_sorted_by_date,
|
| 143 |
+
get_invoice_line_items_sorted_by_price,
|
| 144 |
+
get_employee_by_invoice_and_customer,
|
| 145 |
+
get_invoice_line_items,
|
| 146 |
+
]
|
src/tools/music_catalog.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Music catalog tools for the multi-agent system."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from langchain_core.tools import tool
|
| 5 |
+
from src.db.database import run_query_safe
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _safe_int(value: str, label: str = "value") -> int:
|
| 11 |
+
try:
|
| 12 |
+
return int(value)
|
| 13 |
+
except (ValueError, TypeError):
|
| 14 |
+
raise ValueError(f"Invalid {label}: '{value}'. Please provide a numeric value.")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@tool
|
| 18 |
+
def get_albums_by_artist(artist: str) -> str:
|
| 19 |
+
"""Get albums by an artist from the music catalog. Uses fuzzy matching on artist name."""
|
| 20 |
+
logger.info(f"TOOL_CALL: get_albums_by_artist | artist={artist}")
|
| 21 |
+
try:
|
| 22 |
+
result = run_query_safe(
|
| 23 |
+
"""
|
| 24 |
+
SELECT Album.AlbumId, Album.Title AS AlbumTitle, Artist.Name AS ArtistName
|
| 25 |
+
FROM Album
|
| 26 |
+
JOIN Artist ON Album.ArtistId = Artist.ArtistId
|
| 27 |
+
WHERE Artist.Name LIKE :pattern
|
| 28 |
+
ORDER BY Album.Title;
|
| 29 |
+
""",
|
| 30 |
+
{"pattern": f"%{artist}%"},
|
| 31 |
+
)
|
| 32 |
+
logger.info(f"TOOL_RESULT: get_albums_by_artist | result_length={len(result)}")
|
| 33 |
+
if result == "[]":
|
| 34 |
+
return f"No albums found for artist: {artist}"
|
| 35 |
+
return result
|
| 36 |
+
except Exception as e:
|
| 37 |
+
logger.error(f"Error in get_albums_by_artist: {e}")
|
| 38 |
+
return f"Error looking up albums for '{artist}'. Please try again."
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@tool
|
| 42 |
+
def get_tracks_by_artist(artist: str) -> str:
|
| 43 |
+
"""
|
| 44 |
+
Get songs/tracks by an artist from the catalog.
|
| 45 |
+
Returns total count and a sample of up to 20 tracks with full details.
|
| 46 |
+
"""
|
| 47 |
+
logger.info(f"TOOL_CALL: get_tracks_by_artist | artist={artist}")
|
| 48 |
+
try:
|
| 49 |
+
count_result = run_query_safe(
|
| 50 |
+
"""
|
| 51 |
+
SELECT COUNT(*) AS total_tracks
|
| 52 |
+
FROM Track
|
| 53 |
+
JOIN Album ON Track.AlbumId = Album.AlbumId
|
| 54 |
+
JOIN Artist ON Album.ArtistId = Artist.ArtistId
|
| 55 |
+
WHERE Artist.Name LIKE :pattern;
|
| 56 |
+
""",
|
| 57 |
+
{"pattern": f"%{artist}%"},
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
result = run_query_safe(
|
| 61 |
+
"""
|
| 62 |
+
SELECT Track.TrackId,
|
| 63 |
+
Track.Name AS SongName,
|
| 64 |
+
Artist.Name AS ArtistName,
|
| 65 |
+
Album.Title AS AlbumTitle,
|
| 66 |
+
Genre.Name AS GenreName,
|
| 67 |
+
Track.Composer,
|
| 68 |
+
Track.Milliseconds,
|
| 69 |
+
ROUND(Track.Milliseconds / 60000.0, 1) AS DurationMinutes,
|
| 70 |
+
Track.Bytes,
|
| 71 |
+
Track.UnitPrice,
|
| 72 |
+
MediaType.Name AS MediaType
|
| 73 |
+
FROM Track
|
| 74 |
+
JOIN Album ON Track.AlbumId = Album.AlbumId
|
| 75 |
+
JOIN Artist ON Album.ArtistId = Artist.ArtistId
|
| 76 |
+
LEFT JOIN Genre ON Track.GenreId = Genre.GenreId
|
| 77 |
+
LEFT JOIN MediaType ON Track.MediaTypeId = MediaType.MediaTypeId
|
| 78 |
+
WHERE Artist.Name LIKE :pattern
|
| 79 |
+
ORDER BY Album.Title, Track.Name
|
| 80 |
+
LIMIT 20;
|
| 81 |
+
""",
|
| 82 |
+
{"pattern": f"%{artist}%"},
|
| 83 |
+
)
|
| 84 |
+
logger.info(f"TOOL_RESULT: get_tracks_by_artist | count={count_result} | sample_length={len(result)}")
|
| 85 |
+
|
| 86 |
+
if result == "[]":
|
| 87 |
+
return f"No tracks found for artist: {artist}"
|
| 88 |
+
|
| 89 |
+
return f"Total tracks found: {count_result}. Sample (up to 20): {result}"
|
| 90 |
+
except Exception as e:
|
| 91 |
+
logger.error(f"Error in get_tracks_by_artist: {e}")
|
| 92 |
+
return f"Error looking up tracks for '{artist}'. Please try again."
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@tool
|
| 96 |
+
def get_songs_by_genre(genre: str) -> str:
|
| 97 |
+
"""
|
| 98 |
+
Fetch a representative sample of songs from a specific genre.
|
| 99 |
+
Returns total count and one song per artist (up to 10), deterministically
|
| 100 |
+
picking each artist's first track by TrackId.
|
| 101 |
+
"""
|
| 102 |
+
logger.info(f"TOOL_CALL: get_songs_by_genre | genre={genre}")
|
| 103 |
+
try:
|
| 104 |
+
count_result = run_query_safe(
|
| 105 |
+
"""
|
| 106 |
+
SELECT COUNT(*) AS total_tracks
|
| 107 |
+
FROM Track
|
| 108 |
+
JOIN Genre ON Track.GenreId = Genre.GenreId
|
| 109 |
+
WHERE Genre.Name LIKE :pattern;
|
| 110 |
+
""",
|
| 111 |
+
{"pattern": f"%{genre}%"},
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
result = run_query_safe(
|
| 115 |
+
"""
|
| 116 |
+
WITH ranked AS (
|
| 117 |
+
SELECT Track.TrackId,
|
| 118 |
+
Track.Name AS SongName,
|
| 119 |
+
Artist.Name AS ArtistName,
|
| 120 |
+
Album.Title AS AlbumTitle,
|
| 121 |
+
Genre.Name AS GenreName,
|
| 122 |
+
Track.Composer,
|
| 123 |
+
Track.Milliseconds,
|
| 124 |
+
ROUND(Track.Milliseconds / 60000.0, 1) AS DurationMinutes,
|
| 125 |
+
Track.Bytes,
|
| 126 |
+
Track.UnitPrice,
|
| 127 |
+
MediaType.Name AS MediaType,
|
| 128 |
+
ROW_NUMBER() OVER (PARTITION BY Artist.ArtistId ORDER BY Track.TrackId) AS rn
|
| 129 |
+
FROM Track
|
| 130 |
+
JOIN Genre ON Track.GenreId = Genre.GenreId
|
| 131 |
+
JOIN Album ON Track.AlbumId = Album.AlbumId
|
| 132 |
+
JOIN Artist ON Album.ArtistId = Artist.ArtistId
|
| 133 |
+
LEFT JOIN MediaType ON Track.MediaTypeId = MediaType.MediaTypeId
|
| 134 |
+
WHERE Genre.Name LIKE :pattern
|
| 135 |
+
)
|
| 136 |
+
SELECT TrackId, SongName, ArtistName, AlbumTitle, GenreName,
|
| 137 |
+
Composer, Milliseconds, DurationMinutes, Bytes, UnitPrice, MediaType
|
| 138 |
+
FROM ranked
|
| 139 |
+
WHERE rn = 1
|
| 140 |
+
ORDER BY ArtistName
|
| 141 |
+
LIMIT 10;
|
| 142 |
+
""",
|
| 143 |
+
{"pattern": f"%{genre}%"},
|
| 144 |
+
)
|
| 145 |
+
logger.info(f"TOOL_RESULT: get_songs_by_genre | count={count_result} | sample_length={len(result)}")
|
| 146 |
+
|
| 147 |
+
if result == "[]":
|
| 148 |
+
return f"No songs found for the genre: {genre}"
|
| 149 |
+
|
| 150 |
+
return (
|
| 151 |
+
f"Total {genre} tracks in catalog: {count_result}. "
|
| 152 |
+
f"Representative sample (one per artist, up to 10): {result}"
|
| 153 |
+
)
|
| 154 |
+
except Exception as e:
|
| 155 |
+
logger.error(f"Error in get_songs_by_genre: {e}")
|
| 156 |
+
return f"Error looking up songs for genre '{genre}'. Please try again."
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
@tool
|
| 160 |
+
def check_for_songs(song_title: str) -> str:
|
| 161 |
+
"""
|
| 162 |
+
Check if a song exists in the catalog by its name. Uses fuzzy matching.
|
| 163 |
+
Returns up to 10 matches with full track details.
|
| 164 |
+
"""
|
| 165 |
+
logger.info(f"TOOL_CALL: check_for_songs | song_title={song_title}")
|
| 166 |
+
try:
|
| 167 |
+
result = run_query_safe(
|
| 168 |
+
"""
|
| 169 |
+
SELECT Track.TrackId,
|
| 170 |
+
Track.Name AS SongName,
|
| 171 |
+
Artist.Name AS ArtistName,
|
| 172 |
+
Album.Title AS AlbumTitle,
|
| 173 |
+
Genre.Name AS GenreName,
|
| 174 |
+
Track.Composer,
|
| 175 |
+
Track.Milliseconds,
|
| 176 |
+
ROUND(Track.Milliseconds / 60000.0, 1) AS DurationMinutes,
|
| 177 |
+
Track.Bytes,
|
| 178 |
+
Track.UnitPrice,
|
| 179 |
+
MediaType.Name AS MediaType
|
| 180 |
+
FROM Track
|
| 181 |
+
JOIN Album ON Track.AlbumId = Album.AlbumId
|
| 182 |
+
JOIN Artist ON Album.ArtistId = Artist.ArtistId
|
| 183 |
+
LEFT JOIN Genre ON Track.GenreId = Genre.GenreId
|
| 184 |
+
LEFT JOIN MediaType ON Track.MediaTypeId = MediaType.MediaTypeId
|
| 185 |
+
WHERE Track.Name LIKE :pattern
|
| 186 |
+
ORDER BY Track.Name
|
| 187 |
+
LIMIT 10;
|
| 188 |
+
""",
|
| 189 |
+
{"pattern": f"%{song_title}%"},
|
| 190 |
+
)
|
| 191 |
+
logger.info(f"TOOL_RESULT: check_for_songs | result_length={len(result)}")
|
| 192 |
+
if result == "[]":
|
| 193 |
+
return f"No songs found matching: {song_title}"
|
| 194 |
+
return result
|
| 195 |
+
except Exception as e:
|
| 196 |
+
logger.error(f"Error in check_for_songs: {e}")
|
| 197 |
+
return f"Error looking up song '{song_title}'. Please try again."
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
@tool
|
| 201 |
+
def get_track_details(track_id: str) -> str:
|
| 202 |
+
"""
|
| 203 |
+
Get complete details for a specific track by its TrackId.
|
| 204 |
+
Use this when the customer needs detailed info about a specific known track.
|
| 205 |
+
"""
|
| 206 |
+
logger.info(f"TOOL_CALL: get_track_details | track_id={track_id}")
|
| 207 |
+
try:
|
| 208 |
+
result = run_query_safe(
|
| 209 |
+
"""
|
| 210 |
+
SELECT Track.TrackId,
|
| 211 |
+
Track.Name AS SongName,
|
| 212 |
+
Artist.Name AS ArtistName,
|
| 213 |
+
Album.Title AS AlbumTitle,
|
| 214 |
+
Genre.Name AS GenreName,
|
| 215 |
+
Track.Composer,
|
| 216 |
+
Track.Milliseconds,
|
| 217 |
+
ROUND(Track.Milliseconds / 60000.0, 1) AS DurationMinutes,
|
| 218 |
+
Track.Bytes,
|
| 219 |
+
ROUND(Track.Bytes / 1048576.0, 1) AS SizeMB,
|
| 220 |
+
Track.UnitPrice,
|
| 221 |
+
MediaType.Name AS MediaType
|
| 222 |
+
FROM Track
|
| 223 |
+
LEFT JOIN Album ON Track.AlbumId = Album.AlbumId
|
| 224 |
+
LEFT JOIN Artist ON Album.ArtistId = Artist.ArtistId
|
| 225 |
+
LEFT JOIN Genre ON Track.GenreId = Genre.GenreId
|
| 226 |
+
LEFT JOIN MediaType ON Track.MediaTypeId = MediaType.MediaTypeId
|
| 227 |
+
WHERE Track.TrackId = :track_id;
|
| 228 |
+
""",
|
| 229 |
+
{"track_id": _safe_int(track_id, "track ID")},
|
| 230 |
+
)
|
| 231 |
+
logger.info(f"TOOL_RESULT: get_track_details | result_length={len(result)}")
|
| 232 |
+
if result == "[]":
|
| 233 |
+
return f"No track found with TrackId: {track_id}"
|
| 234 |
+
return result
|
| 235 |
+
except Exception as e:
|
| 236 |
+
logger.error(f"Error in get_track_details: {e}")
|
| 237 |
+
return f"Error looking up track {track_id}. Please try again."
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
music_tools = [
|
| 241 |
+
get_albums_by_artist,
|
| 242 |
+
get_tracks_by_artist,
|
| 243 |
+
get_songs_by_genre,
|
| 244 |
+
check_for_songs,
|
| 245 |
+
get_track_details,
|
| 246 |
+
]
|
src/ui/__init__.py
ADDED
|
File without changes
|
src/ui/app.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gradio application for the Music Store Multi-Agent System."""
|
| 2 |
+
|
| 3 |
+
import uuid
|
| 4 |
+
import time
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
import gradio as gr
|
| 8 |
+
from langchain_core.messages import HumanMessage, AIMessage
|
| 9 |
+
|
| 10 |
+
from src.config import settings
|
| 11 |
+
from src.db.database import verify_database
|
| 12 |
+
from src.agents.graph import build_graph
|
| 13 |
+
from src.ui.styles import CUSTOM_CSS
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
# Module-level state
|
| 18 |
+
_graph = None
|
| 19 |
+
_checkpointer = None
|
| 20 |
+
_store = None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def initialize():
|
| 24 |
+
global _graph, _checkpointer, _store
|
| 25 |
+
|
| 26 |
+
logger.info("Initializing Music Store Agent...")
|
| 27 |
+
|
| 28 |
+
db_health = verify_database()
|
| 29 |
+
if db_health.get("status") != "healthy":
|
| 30 |
+
logger.error(f"Database unhealthy: {db_health}")
|
| 31 |
+
else:
|
| 32 |
+
logger.info(f"Database healthy: {db_health['tables']}")
|
| 33 |
+
|
| 34 |
+
try:
|
| 35 |
+
_graph, _checkpointer, _store = build_graph(
|
| 36 |
+
model_name=settings.model_name,
|
| 37 |
+
temperature=settings.temperature,
|
| 38 |
+
openai_api_key=settings.openai_api_key or None,
|
| 39 |
+
openai_api_base=settings.openai_api_base or None,
|
| 40 |
+
)
|
| 41 |
+
logger.info("Agent graph built successfully.")
|
| 42 |
+
except Exception as e:
|
| 43 |
+
logger.error(f"Failed to build agent graph: {e}")
|
| 44 |
+
raise
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _status_html(status: str, message: str, tools_used: list = None) -> str:
|
| 48 |
+
colors = {
|
| 49 |
+
"success": "#10b981",
|
| 50 |
+
"error": "#ef4444",
|
| 51 |
+
"warning": "#f59e0b",
|
| 52 |
+
"waiting": "#6366f1",
|
| 53 |
+
"idle": "#6b7280",
|
| 54 |
+
}
|
| 55 |
+
icons = {
|
| 56 |
+
"success": "✓",
|
| 57 |
+
"error": "✗",
|
| 58 |
+
"warning": "⚠",
|
| 59 |
+
"waiting": "⏳",
|
| 60 |
+
"idle": "●",
|
| 61 |
+
}
|
| 62 |
+
color = colors.get(status, "#6b7280")
|
| 63 |
+
icon = icons.get(status, "●")
|
| 64 |
+
|
| 65 |
+
tools_text = ""
|
| 66 |
+
if tools_used:
|
| 67 |
+
tools_text = f" | Data sources: {', '.join(set(tools_used))}"
|
| 68 |
+
|
| 69 |
+
return (
|
| 70 |
+
f'<div style="display:flex;align-items:center;gap:6px;padding:6px 12px;'
|
| 71 |
+
f'border-radius:6px;background:{color}15;border:1px solid {color}30;'
|
| 72 |
+
f'font-size:13px;color:{color};">'
|
| 73 |
+
f'<span>{icon}</span>'
|
| 74 |
+
f'<span>{message}{tools_text}</span>'
|
| 75 |
+
f'</div>'
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def reset_conversation() -> tuple:
|
| 80 |
+
new_thread = str(uuid.uuid4())
|
| 81 |
+
logger.info(f"Conversation reset. New thread_id={new_thread}")
|
| 82 |
+
return [], new_thread, _status_html("idle", "New conversation started")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def show_user_message(message, history, tid):
|
| 86 |
+
if not message.strip():
|
| 87 |
+
return history, "", tid, _status_html("idle", "Ready")
|
| 88 |
+
if not tid:
|
| 89 |
+
tid = str(uuid.uuid4())
|
| 90 |
+
history = history + [{"role": "user", "content": message}]
|
| 91 |
+
return history, "", tid, _status_html("waiting", "Processing...")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def generate_response(history, tid):
|
| 95 |
+
if not history:
|
| 96 |
+
return history, tid, _status_html("idle", "Ready")
|
| 97 |
+
|
| 98 |
+
user_message = None
|
| 99 |
+
for msg in reversed(history):
|
| 100 |
+
if msg.get("role") == "user":
|
| 101 |
+
user_message = msg["content"]
|
| 102 |
+
break
|
| 103 |
+
|
| 104 |
+
if not user_message:
|
| 105 |
+
return history, tid, _status_html("idle", "Ready")
|
| 106 |
+
|
| 107 |
+
if not _graph:
|
| 108 |
+
history.append({"role": "assistant", "content": "System not initialized. Please refresh the page."})
|
| 109 |
+
return history, tid, _status_html("error", "System not initialized")
|
| 110 |
+
|
| 111 |
+
config = {"configurable": {"thread_id": tid}}
|
| 112 |
+
|
| 113 |
+
try:
|
| 114 |
+
start_time = time.time()
|
| 115 |
+
input_state = {"messages": [HumanMessage(content=user_message)]}
|
| 116 |
+
|
| 117 |
+
final_response = None
|
| 118 |
+
tools_used = []
|
| 119 |
+
|
| 120 |
+
for event in _graph.stream(input_state, config=config, stream_mode="updates"):
|
| 121 |
+
for node_name, node_output in event.items():
|
| 122 |
+
logger.info(f"Graph event: node={node_name}")
|
| 123 |
+
|
| 124 |
+
if node_name in ("music_tool_node",):
|
| 125 |
+
tools_used.append("music_catalog")
|
| 126 |
+
elif node_name == "invoice_information_subagent":
|
| 127 |
+
tools_used.append("invoice_lookup")
|
| 128 |
+
|
| 129 |
+
if isinstance(node_output, dict) and "messages" in node_output:
|
| 130 |
+
for msg in node_output["messages"]:
|
| 131 |
+
if isinstance(msg, AIMessage) and msg.content:
|
| 132 |
+
final_response = msg.content
|
| 133 |
+
|
| 134 |
+
elapsed = time.time() - start_time
|
| 135 |
+
|
| 136 |
+
if final_response:
|
| 137 |
+
history.append({"role": "assistant", "content": final_response})
|
| 138 |
+
status_out = _status_html(
|
| 139 |
+
"success",
|
| 140 |
+
f"Responded in {elapsed:.1f}s",
|
| 141 |
+
tools_used=tools_used,
|
| 142 |
+
)
|
| 143 |
+
else:
|
| 144 |
+
snapshot = _graph.get_state(config)
|
| 145 |
+
if snapshot and hasattr(snapshot, "next") and snapshot.next:
|
| 146 |
+
state_messages = snapshot.values.get("messages", [])
|
| 147 |
+
for msg in reversed(state_messages):
|
| 148 |
+
if isinstance(msg, AIMessage) and msg.content:
|
| 149 |
+
final_response = msg.content
|
| 150 |
+
break
|
| 151 |
+
|
| 152 |
+
if final_response and not any(
|
| 153 |
+
h.get("content") == final_response for h in history if h.get("role") == "assistant"
|
| 154 |
+
):
|
| 155 |
+
history.append({"role": "assistant", "content": final_response})
|
| 156 |
+
|
| 157 |
+
status_out = _status_html("waiting", "Waiting for your input")
|
| 158 |
+
else:
|
| 159 |
+
history.append({
|
| 160 |
+
"role": "assistant",
|
| 161 |
+
"content": "I'm sorry, I wasn't able to generate a response. Please try rephrasing your question.",
|
| 162 |
+
})
|
| 163 |
+
status_out = _status_html("warning", "No response generated")
|
| 164 |
+
|
| 165 |
+
return history, tid, status_out
|
| 166 |
+
|
| 167 |
+
except Exception as e:
|
| 168 |
+
logger.error(f"Error processing message: {e}", exc_info=True)
|
| 169 |
+
history.append({"role": "assistant", "content": "I encountered an error. Please try again."})
|
| 170 |
+
return history, tid, _status_html("error", str(e)[:100])
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def create_app() -> gr.Blocks:
|
| 174 |
+
initialize()
|
| 175 |
+
|
| 176 |
+
with gr.Blocks(
|
| 177 |
+
title=settings.app_title,
|
| 178 |
+
css=CUSTOM_CSS,
|
| 179 |
+
theme=gr.themes.Soft(
|
| 180 |
+
primary_hue="blue",
|
| 181 |
+
secondary_hue="slate",
|
| 182 |
+
neutral_hue="slate",
|
| 183 |
+
font=gr.themes.GoogleFont("Inter"),
|
| 184 |
+
),
|
| 185 |
+
) as app:
|
| 186 |
+
|
| 187 |
+
thread_id = gr.State(value="")
|
| 188 |
+
|
| 189 |
+
gr.HTML(
|
| 190 |
+
f"""
|
| 191 |
+
<div class="app-header">
|
| 192 |
+
<h1>{settings.app_title}</h1>
|
| 193 |
+
<p>{settings.app_description}</p>
|
| 194 |
+
</div>
|
| 195 |
+
"""
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
chatbot = gr.Chatbot(
|
| 199 |
+
value=[],
|
| 200 |
+
type="messages",
|
| 201 |
+
height=480,
|
| 202 |
+
show_label=False,
|
| 203 |
+
avatar_images=(None, "https://api.dicebear.com/7.x/bottts/svg?seed=music"),
|
| 204 |
+
elem_classes=["chatbot-container"],
|
| 205 |
+
placeholder=(
|
| 206 |
+
"**Welcome!** Type your message below to get started.\n\n"
|
| 207 |
+
"Try: *\"My customer ID is 5\"* or *\"What rock albums do you have?\"*"
|
| 208 |
+
),
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
status = gr.HTML(
|
| 212 |
+
value=_status_html("idle", "Ready — type a message to begin"),
|
| 213 |
+
elem_classes=["status-bar"],
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
with gr.Row():
|
| 217 |
+
msg_input = gr.Textbox(
|
| 218 |
+
placeholder="Type your message here...",
|
| 219 |
+
show_label=False,
|
| 220 |
+
scale=6,
|
| 221 |
+
container=False,
|
| 222 |
+
autofocus=True,
|
| 223 |
+
)
|
| 224 |
+
send_btn = gr.Button("Send", variant="primary", scale=1, min_width=80)
|
| 225 |
+
|
| 226 |
+
with gr.Row():
|
| 227 |
+
reset_btn = gr.Button("New Conversation", size="sm", variant="secondary")
|
| 228 |
+
|
| 229 |
+
gr.HTML(
|
| 230 |
+
'<div class="app-footer">'
|
| 231 |
+
'Powered by LangGraph Multi-Agent Architecture · '
|
| 232 |
+
'Data from Chinook Sample Database'
|
| 233 |
+
'</div>'
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
send_btn.click(
|
| 237 |
+
fn=show_user_message,
|
| 238 |
+
inputs=[msg_input, chatbot, thread_id],
|
| 239 |
+
outputs=[chatbot, msg_input, thread_id, status],
|
| 240 |
+
).then(
|
| 241 |
+
fn=generate_response,
|
| 242 |
+
inputs=[chatbot, thread_id],
|
| 243 |
+
outputs=[chatbot, thread_id, status],
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
msg_input.submit(
|
| 247 |
+
fn=show_user_message,
|
| 248 |
+
inputs=[msg_input, chatbot, thread_id],
|
| 249 |
+
outputs=[chatbot, msg_input, thread_id, status],
|
| 250 |
+
).then(
|
| 251 |
+
fn=generate_response,
|
| 252 |
+
inputs=[chatbot, thread_id],
|
| 253 |
+
outputs=[chatbot, thread_id, status],
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
reset_btn.click(
|
| 257 |
+
fn=reset_conversation,
|
| 258 |
+
inputs=[],
|
| 259 |
+
outputs=[chatbot, thread_id, status],
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
return app
|
src/ui/styles.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CSS styles for the Gradio application."""
|
| 2 |
+
|
| 3 |
+
CUSTOM_CSS = """
|
| 4 |
+
.gradio-container {
|
| 5 |
+
max-width: 900px !important;
|
| 6 |
+
margin: 0 auto !important;
|
| 7 |
+
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif !important;
|
| 8 |
+
}
|
| 9 |
+
.app-header {
|
| 10 |
+
text-align: center;
|
| 11 |
+
padding: 24px 16px 16px;
|
| 12 |
+
border-bottom: 1px solid #e5e7eb;
|
| 13 |
+
margin-bottom: 16px;
|
| 14 |
+
}
|
| 15 |
+
.app-header h1 {
|
| 16 |
+
font-size: 28px;
|
| 17 |
+
font-weight: 700;
|
| 18 |
+
color: #1f2937;
|
| 19 |
+
margin: 0 0 8px;
|
| 20 |
+
}
|
| 21 |
+
.app-header p {
|
| 22 |
+
font-size: 14px;
|
| 23 |
+
color: #6b7280;
|
| 24 |
+
margin: 0;
|
| 25 |
+
line-height: 1.5;
|
| 26 |
+
}
|
| 27 |
+
.chatbot-container {
|
| 28 |
+
border: 1px solid #e5e7eb !important;
|
| 29 |
+
border-radius: 12px !important;
|
| 30 |
+
overflow: hidden;
|
| 31 |
+
}
|
| 32 |
+
.status-bar {
|
| 33 |
+
min-height: 36px;
|
| 34 |
+
}
|
| 35 |
+
.app-footer {
|
| 36 |
+
text-align: center;
|
| 37 |
+
padding: 12px;
|
| 38 |
+
font-size: 12px;
|
| 39 |
+
color: #9ca3af;
|
| 40 |
+
border-top: 1px solid #f3f4f6;
|
| 41 |
+
margin-top: 8px;
|
| 42 |
+
}
|
| 43 |
+
@media (max-width: 640px) {
|
| 44 |
+
.app-header h1 { font-size: 22px; }
|
| 45 |
+
}
|
| 46 |
+
"""
|