adilpanwar commited on
Commit
927c050
·
verified ·
1 Parent(s): 9e6cbd4

Upload 16 files

Browse files
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
+ """