vicfeuga commited on
Commit
e55ff77
Β·
verified Β·
1 Parent(s): c368cf9

Upload 5 files

Browse files
agents/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from agents.supervisor import (
2
+ build_chatbot_graph,
3
+ route_after_analysis,
4
+ route_after_data_extraction,
5
+ run_user_query,
6
+ )
7
+
8
+ __all__ = [
9
+ "build_chatbot_graph",
10
+ "route_after_analysis",
11
+ "route_after_data_extraction",
12
+ "run_user_query",
13
+ ]
agents/data_extraction.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data extraction agent: builds the graph node that extracts metrics data
3
+ based on user queries and filters.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from typing import Callable
8
+
9
+ import pandas as pd
10
+
11
+ from tools import data_extractor, filter_extractor
12
+ from tools.state import GraphState
13
+
14
+ _DEFAULT_FILTER_COLUMNS = [
15
+ "Region", "Period", "Cluster", "Product", "Calculation_Type", "TA Market", "Class", "Level",
16
+ ]
17
+
18
+
19
+ def _tool_call(tool_or_fn, **kwargs):
20
+ """Invoke a tool or callable, handling both raw functions and tool wrappers."""
21
+ callable_obj = getattr(tool_or_fn, "func", tool_or_fn)
22
+ return callable_obj(**kwargs)
23
+
24
+
25
+ def _candidate_values(df: pd.DataFrame, max_values: int = 100) -> dict[str, list[str]]:
26
+ values: dict[str, list[str]] = {}
27
+ for column in df.columns:
28
+ unique_values = df[column].dropna().astype(str).str.strip().unique().tolist()
29
+ values[column] = sorted([v for v in unique_values if v])[:max_values]
30
+ return values
31
+
32
+
33
+ def _validate_filters(filters: dict, columns: list[str]) -> dict:
34
+ allowed = set(columns)
35
+ validated = {}
36
+ for key, value in (filters or {}).items():
37
+ if key not in allowed:
38
+ continue
39
+ if isinstance(value, list):
40
+ cleaned = [str(v).strip() for v in value if str(v).strip()]
41
+ if cleaned:
42
+ validated[key] = cleaned
43
+ return validated
44
+
45
+
46
+ def build_data_extraction_node(
47
+ metrics_df: pd.DataFrame | None = None,
48
+ llm_invoke: Callable[[str], object] | None = None,
49
+ db_query_fn: Callable[[dict], pd.DataFrame] | None = None,
50
+ column_values: dict[str, list[str]] | None = None,
51
+ ):
52
+ """
53
+ Factory that returns a data extraction node for the LangGraph.
54
+
55
+ SQL mode (preferred):
56
+ Pass ``db_query_fn`` and ``column_values``. Filters are extracted from the
57
+ user message, then a targeted SQL query is executed.
58
+
59
+ In-memory mode (legacy):
60
+ Pass ``metrics_df`` (the full metrics DataFrame). Filters are applied in
61
+ memory via pandas.
62
+ """
63
+ if db_query_fn is not None:
64
+ available_columns = list(column_values.keys()) if column_values else _DEFAULT_FILTER_COLUMNS
65
+ value_map: dict[str, list[str]] = column_values or {}
66
+ else:
67
+ if metrics_df is None:
68
+ raise ValueError("Either metrics_df or db_query_fn must be provided.")
69
+ available_columns = metrics_df.columns.tolist()
70
+ value_map = _candidate_values(metrics_df)
71
+
72
+ def data_extraction_node(state: GraphState) -> dict:
73
+ user_query = state.get("user_query", "")
74
+ prior_filters = _validate_filters(state.get("filters", {}), available_columns)
75
+ conversation_history = state.get("conversation_history", []) or []
76
+
77
+ raw_filters = _tool_call(
78
+ filter_extractor,
79
+ user_query=user_query,
80
+ available_columns=available_columns,
81
+ column_values=value_map,
82
+ llm=llm_invoke,
83
+ prior_filters=prior_filters,
84
+ conversation_history=conversation_history,
85
+ )
86
+
87
+ filters = _validate_filters(raw_filters, available_columns)
88
+
89
+ if db_query_fn is not None:
90
+ fetched_df = db_query_fn(filters)
91
+ rows = fetched_df.to_dict(orient="records") if not fetched_df.empty else []
92
+ else:
93
+ rows = _tool_call(data_extractor, filters=filters, dataframe=metrics_df)
94
+
95
+ error_message = state.get("error_message", "")
96
+ if not rows:
97
+ error_message = "I couldn't find any data matching your criteria."
98
+
99
+ return {
100
+ "filters": filters,
101
+ "extracted_data": rows,
102
+ "error_message": error_message,
103
+ }
104
+
105
+ return data_extraction_node
agents/data_knowledge.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data knowledge agent: builds the graph node that retrieves parameter/configuration
3
+ information based on user queries (e.g. market mappings, cluster definitions).
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from tools import parameter_reader
8
+ from tools.state import GraphState
9
+
10
+
11
+ def _tool_call(tool_or_fn, **kwargs):
12
+ callable_obj = getattr(tool_or_fn, "func", tool_or_fn)
13
+ return callable_obj(**kwargs)
14
+
15
+
16
+ def build_data_knowledge_node():
17
+ """
18
+ Factory that returns a data knowledge node for the graph.
19
+ The node uses the parameter reader to fetch relevant parameter info for the user query.
20
+ """
21
+ def data_knowledge_node(state: GraphState) -> dict:
22
+ user_query = state.get("user_query", "")
23
+ rows = _tool_call(parameter_reader, user_query=user_query)
24
+
25
+ error_message = state.get("error_message", "")
26
+ if not rows and not error_message:
27
+ error_message = "I couldn't find relevant parameter information for your request."
28
+
29
+ return {
30
+ "parameter_data": rows,
31
+ "error_message": error_message,
32
+ }
33
+
34
+ return data_knowledge_node
agents/query_analyzer.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Query analyzer agent: classifies user intent (data retrieval, parameter info, or out-of-scope)
3
+ to route the chatbot to the appropriate handler.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from typing import Callable
9
+
10
+ from tools.state import GraphState
11
+
12
+
13
+ def _format_history(history: list[dict], max_turns: int = 6) -> str:
14
+ if not history:
15
+ return ""
16
+ clipped = history[-max_turns:]
17
+ lines = []
18
+ for msg in clipped:
19
+ role = str(msg.get("role", "user")).strip().lower()
20
+ content = str(msg.get("content", "")).strip()
21
+ if content:
22
+ lines.append(f"{role}: {content}")
23
+ return "\n".join(lines)
24
+
25
+
26
+ def build_query_analyzer_node(
27
+ llm_json_call: Callable[[str], dict] | None = None,
28
+ ):
29
+ """
30
+ Factory that returns a query analyzer node for the graph.
31
+ The node classifies user intent via LLM into: data_retrieval, parameter_info,
32
+ both, or out_of_scope.
33
+ """
34
+ def query_analyzer_node(state: GraphState) -> dict:
35
+ user_query = state.get("user_query", "")
36
+ intent, is_valid, error = "out_of_scope", False, ""
37
+ prior_filters = state.get("filters", {}) or {}
38
+ history_text = _format_history(state.get("conversation_history", []) or [])
39
+
40
+ if llm_json_call is not None:
41
+ prompt = f"""
42
+ You are a query classifier for a pharma market metrics chatbot.
43
+ Classify user queries into one intent:
44
+ - data_retrieval: user wants metrics/data (volume, share, growth, etc.)
45
+ - parameter_info: user asks about parameters (cluster mapping, regions, etc.)
46
+ - both: user asks for data AND parameter information at the same time
47
+ - out_of_scope: unrelated topics (weather, general knowledge, etc.)
48
+
49
+ Return strict JSON only with keys:
50
+ intent, is_valid, error_message, confidence
51
+
52
+ CRITICAL: Set is_valid=true for data_retrieval when the user asks a data question,
53
+ including when they want to CHANGE filters (e.g. "same but for Spain", "what about Germany",
54
+ "do the same in LATAM"). Do NOT set is_valid=false just because the user requests
55
+ different country/region/period than previously applied - the filter extraction step
56
+ will handle that. Only set is_valid=false for genuinely out-of-scope questions.
57
+
58
+ User query: {user_query}
59
+ Previously applied filters: {json.dumps(prior_filters, ensure_ascii=True)}
60
+ Recent conversation:
61
+ {history_text}
62
+ """
63
+ try:
64
+ result = llm_json_call(prompt)
65
+ if isinstance(result, str):
66
+ result = json.loads(result)
67
+ intent = result.get("intent", intent)
68
+ is_valid = bool(result.get("is_valid", is_valid))
69
+ error = result.get("error_message", error) or error
70
+ except Exception:
71
+ pass
72
+
73
+ if intent not in {"data_retrieval", "parameter_info", "both", "out_of_scope"}:
74
+ intent = "out_of_scope"
75
+ is_valid = False
76
+
77
+ if intent == "out_of_scope" and not error:
78
+ error = (
79
+ "This question is outside my knowledge domain. I can help with data "
80
+ "queries and parameter information about the demo pharma market."
81
+ )
82
+
83
+ return {
84
+ "query_intent": intent,
85
+ "is_valid": is_valid,
86
+ "error_message": error,
87
+ }
88
+
89
+ return query_analyzer_node
agents/supervisor.py ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from typing import Any, Callable
6
+
7
+ import pandas as pd
8
+
9
+ from agents.data_extraction import build_data_extraction_node
10
+ from agents.data_knowledge import build_data_knowledge_node
11
+ from agents.query_analyzer import build_query_analyzer_node
12
+ from tools.leading_country_tools import calculate_leading_country, wants_leading_country
13
+ from tools.state import GraphState, create_initial_state
14
+
15
+
16
+ LOG_PREFIX = "[Supervisor]"
17
+
18
+
19
+ def _log(msg: str) -> None:
20
+ print(f"{LOG_PREFIX} {msg}")
21
+
22
+
23
+ def _wrap_node(name: str, node_fn: Callable[[GraphState], dict]) -> Callable[[GraphState], dict]:
24
+ """Wrap a graph node to log entry/exit and a one-line summary to the terminal."""
25
+
26
+ def wrapped(state: GraphState) -> dict:
27
+ _log(f"β†’ Entering node: {name}")
28
+ result = node_fn(state)
29
+ summary_parts = []
30
+ if result.get("error_message"):
31
+ err = (result["error_message"] or "")[:60]
32
+ summary_parts.append(f"error={err!r}...")
33
+ if "extracted_data" in result:
34
+ n = len(result.get("extracted_data") or [])
35
+ summary_parts.append(f"extracted_data={n} rows")
36
+ if "parameter_data" in result:
37
+ n = len(result.get("parameter_data") or [])
38
+ summary_parts.append(f"parameter_data={n} rows")
39
+ if "leading_country_result" in result:
40
+ n = len(result.get("leading_country_result") or [])
41
+ summary_parts.append(f"leading_country_result={n} rows")
42
+ if "query_intent" in result:
43
+ summary_parts.append(f"intent={result['query_intent']!r} is_valid={result.get('is_valid', '?')}")
44
+ if "filters" in result and result["filters"]:
45
+ summary_parts.append(f"filters={list(result['filters'].keys())}")
46
+ _log(f"← Exiting node: {name}" + (f" ({', '.join(summary_parts)})" if summary_parts else ""))
47
+ return result
48
+
49
+ return wrapped
50
+
51
+
52
+ def _invoke_llm_json(client: Any, prompt: str) -> dict:
53
+ response = client.chat.completions.create(
54
+ model=client._default_deployment, # type: ignore[attr-defined]
55
+ messages=[
56
+ {"role": "system", "content": "Return strict JSON only."},
57
+ {"role": "user", "content": prompt},
58
+ ],
59
+ temperature=0,
60
+ )
61
+ content = response.choices[0].message.content or "{}"
62
+ try:
63
+ return json.loads(content)
64
+ except Exception:
65
+ match = re.search(r"\{.*\}", content, flags=re.DOTALL)
66
+ if not match:
67
+ return {}
68
+ try:
69
+ return json.loads(match.group(0))
70
+ except Exception:
71
+ return {}
72
+
73
+
74
+ def _invoke_llm_text(client: Any, prompt: str):
75
+ response = client.chat.completions.create(
76
+ model=client._default_deployment, # type: ignore[attr-defined]
77
+ messages=[{"role": "user", "content": prompt}],
78
+ temperature=0,
79
+ )
80
+ return response.choices[0].message.content or ""
81
+
82
+
83
+ def _format_rows(rows: list[dict], max_rows: int = 10) -> str:
84
+ if not rows:
85
+ return ""
86
+ df = pd.DataFrame(rows[:max_rows])
87
+ return df.to_csv(index=False)
88
+
89
+
90
+ def _deterministic_final_response(state: GraphState) -> str:
91
+ if state.get("error_message"):
92
+ _log("generate_response: returning error_message as final_response")
93
+ return state["error_message"]
94
+
95
+ sections: list[str] = []
96
+ extracted_data = state.get("extracted_data", [])
97
+ leading_country_result = state.get("leading_country_result", [])
98
+ parameter_data = state.get("parameter_data", [])
99
+
100
+ if extracted_data:
101
+ sections.append(f"Found {len(extracted_data)} matching data rows.")
102
+ sections.append("Sample data:")
103
+ sections.append(f"```csv\n{_format_rows(extracted_data)}\n```")
104
+
105
+ if parameter_data:
106
+ sections.append(f"Found {len(parameter_data)} relevant parameter rows.")
107
+ sections.append("Reference data:")
108
+ sections.append(f"```csv\n{_format_rows(parameter_data)}\n```")
109
+
110
+ if leading_country_result:
111
+ sections.append(f"Calculated {len(leading_country_result)} leading-country result rows.")
112
+ sections.append("Leading-country calculations:")
113
+ sections.append(f"```csv\n{_format_rows(leading_country_result)}\n```")
114
+
115
+ if not sections:
116
+ _log("generate_response: no data β†’ asking user to clarify")
117
+ return "Could you please clarify what you're looking for?"
118
+
119
+ sections.append("If you want, I can narrow this further by region, period, product, or market.")
120
+ return "\n\n".join(sections)
121
+
122
+
123
+ def _format_history(history: list[dict], max_turns: int = 6) -> str:
124
+ if not history:
125
+ return ""
126
+ clipped = history[-max_turns:]
127
+ lines: list[str] = []
128
+ for msg in clipped:
129
+ role = str(msg.get("role", "user")).strip().lower()
130
+ content = str(msg.get("content", "")).strip()
131
+ if not content:
132
+ continue
133
+ lines.append(f"{role}: {content}")
134
+ return "\n".join(lines)
135
+
136
+
137
+ def build_generate_response_node(
138
+ llm_text_call: Callable[[str], object] | None = None,
139
+ ):
140
+ def generate_response_node(state: GraphState) -> dict:
141
+ fallback_response = _deterministic_final_response(state)
142
+
143
+ if llm_text_call is None:
144
+ _log("generate_response: llm unavailable, using deterministic response")
145
+ return {"final_response": fallback_response}
146
+
147
+ extracted_data = state.get("extracted_data", [])
148
+ leading_country_result = state.get("leading_country_result", [])
149
+ parameter_data = state.get("parameter_data", [])
150
+ filters = state.get("filters", {}) or {}
151
+ conversation_history = state.get("conversation_history", []) or []
152
+ user_query = state.get("user_query", "")
153
+
154
+ prompt = f"""
155
+ You are the supervisor response writer for a pharma market metrics assistant.
156
+ Write the final answer to the user based only on the context below.
157
+
158
+ Rules:
159
+ - Use only facts from the provided context. Do not invent numbers, metrics, or mappings.
160
+ - Keep the answer concise and business-friendly.
161
+ - Mention applied filters (Region, Period, Calculation_Type, Cluster) briefly.
162
+ - When rank columns are present (Current_Volume_Rank, Current_Value_Rank), use them directly
163
+ to determine ordering β€” the lowest rank number is the best-performing product.
164
+ - If the data contains the requested information, answer directly and confidently. Do NOT ask
165
+ clarifying questions when the data is sufficient to answer.
166
+ - Only ask a clarifying question if critical information is genuinely missing (e.g. no data returned).
167
+ - If Leading-country calculations are present, use them as the source of truth.
168
+ - Do not say absolute contribution is unavailable when Current_Value/Current_Volume and Growth fields
169
+ are available; the leading-country calculation reconstructs deltas from those fields.
170
+
171
+ Context:
172
+ User query: {user_query}
173
+ Detected intent: {state.get("query_intent", "out_of_scope")}
174
+ Error message: {state.get("error_message", "")}
175
+ Applied filters: {json.dumps(filters, ensure_ascii=True)}
176
+ Extracted data row count: {len(extracted_data)}
177
+ Extracted data:
178
+ {_format_rows(extracted_data, max_rows=40)}
179
+ Leading-country calculation row count: {len(leading_country_result)}
180
+ Leading-country calculations:
181
+ {_format_rows(leading_country_result, max_rows=40)}
182
+ Parameter row count: {len(parameter_data)}
183
+ Parameter data sample:
184
+ {_format_rows(parameter_data, max_rows=100)}
185
+ Recent conversation history:
186
+ {_format_history(conversation_history)}
187
+
188
+ Return plain text only. No markdown code fences.
189
+ """
190
+ try:
191
+ generated = llm_text_call(prompt)
192
+ text = getattr(generated, "content", str(generated)).strip()
193
+ if text:
194
+ _log(
195
+ "generate_response: llm synthesis success "
196
+ f"(extracted={len(extracted_data)}, parameter={len(parameter_data)} rows)"
197
+ )
198
+ return {"final_response": text}
199
+ except Exception as exc:
200
+ _log(f"generate_response: llm synthesis failed, using fallback ({exc})")
201
+
202
+ _log(
203
+ "generate_response: using deterministic fallback "
204
+ f"(extracted={len(extracted_data)}, parameter={len(parameter_data)} rows)"
205
+ )
206
+ return {"final_response": fallback_response}
207
+
208
+ return generate_response_node
209
+
210
+
211
+ def build_leading_country_node(
212
+ db_query_fn: Any | None = None,
213
+ metrics_df: pd.DataFrame | None = None,
214
+ ):
215
+ def leading_country_node(state: GraphState) -> dict:
216
+ results = calculate_leading_country(
217
+ user_query=state.get("user_query", ""),
218
+ filters=state.get("filters", {}) or {},
219
+ extracted_rows=state.get("extracted_data", []) or [],
220
+ db_query_fn=db_query_fn,
221
+ metrics_df=metrics_df,
222
+ )
223
+ return {"leading_country_result": results}
224
+
225
+ return leading_country_node
226
+
227
+
228
+ def route_after_analysis(state: GraphState) -> str:
229
+ is_valid = state.get("is_valid", False)
230
+ intent = state.get("query_intent", "out_of_scope")
231
+ if not is_valid:
232
+ _log(f"route_after_analysis: is_valid=False β†’ generate_response")
233
+ return "generate_response"
234
+ if intent == "data_retrieval":
235
+ _log(f"route_after_analysis: intent={intent!r} β†’ data_extraction")
236
+ return "data_extraction"
237
+ if intent == "parameter_info":
238
+ _log(f"route_after_analysis: intent={intent!r} β†’ data_knowledge")
239
+ return "data_knowledge"
240
+ if intent == "both":
241
+ _log(f"route_after_analysis: intent={intent!r} β†’ data_extraction (then data_knowledge)")
242
+ return "data_extraction"
243
+ _log(f"route_after_analysis: intent={intent!r} β†’ generate_response")
244
+ return "generate_response"
245
+
246
+
247
+ def route_after_data_extraction(state: GraphState) -> str:
248
+ if wants_leading_country(state.get("user_query", "")):
249
+ _log("route_after_data_extraction: leading-country query β†’ leading_country")
250
+ return "leading_country"
251
+ if state.get("query_intent") == "both":
252
+ _log("route_after_data_extraction: intent=both β†’ data_knowledge")
253
+ return "data_knowledge"
254
+ _log("route_after_data_extraction: β†’ generate_response")
255
+ return "generate_response"
256
+
257
+
258
+ def route_after_leading_country(state: GraphState) -> str:
259
+ if state.get("query_intent") == "both":
260
+ _log("route_after_leading_country: intent=both β†’ data_knowledge")
261
+ return "data_knowledge"
262
+ _log("route_after_leading_country: β†’ generate_response")
263
+ return "generate_response"
264
+
265
+
266
+ def build_chatbot_graph(
267
+ metrics_df: pd.DataFrame | None = None,
268
+ azure_openai_client: Any | None = None,
269
+ db_query_fn: Any | None = None,
270
+ column_values: dict | None = None,
271
+ ):
272
+ """
273
+ Build and compile the LangGraph chatbot.
274
+
275
+ ``azure_openai_client`` is named for backward compatibility with the original
276
+ codebase but actually accepts ANY OpenAI-compatible chat client that exposes
277
+ ``client.chat.completions.create(...)`` β€” including
278
+ ``huggingface_hub.InferenceClient``.
279
+ """
280
+ try:
281
+ from langgraph.graph import END, StateGraph
282
+ except Exception as exc:
283
+ raise ImportError(
284
+ "LangGraph is required. Install with `pip install langgraph`."
285
+ ) from exc
286
+
287
+ llm_json = None
288
+ llm_text = None
289
+ if azure_openai_client is not None:
290
+ llm_json = lambda prompt: _invoke_llm_json(azure_openai_client, prompt)
291
+ llm_text = lambda prompt: _invoke_llm_text(azure_openai_client, prompt)
292
+
293
+ workflow = StateGraph(GraphState)
294
+
295
+ workflow.add_node(
296
+ "query_analyzer",
297
+ _wrap_node("query_analyzer", build_query_analyzer_node(llm_json_call=llm_json)),
298
+ )
299
+ workflow.add_node(
300
+ "data_extraction",
301
+ _wrap_node(
302
+ "data_extraction",
303
+ build_data_extraction_node(
304
+ metrics_df=metrics_df,
305
+ llm_invoke=llm_text,
306
+ db_query_fn=db_query_fn,
307
+ column_values=column_values,
308
+ ),
309
+ ),
310
+ )
311
+ workflow.add_node(
312
+ "data_knowledge",
313
+ _wrap_node("data_knowledge", build_data_knowledge_node()),
314
+ )
315
+ workflow.add_node(
316
+ "leading_country",
317
+ _wrap_node(
318
+ "leading_country",
319
+ build_leading_country_node(db_query_fn=db_query_fn, metrics_df=metrics_df),
320
+ ),
321
+ )
322
+ workflow.add_node(
323
+ "generate_response",
324
+ _wrap_node("generate_response", build_generate_response_node(llm_text_call=llm_text)),
325
+ )
326
+
327
+ workflow.set_entry_point("query_analyzer")
328
+ workflow.add_conditional_edges(
329
+ "query_analyzer",
330
+ route_after_analysis,
331
+ {
332
+ "data_extraction": "data_extraction",
333
+ "data_knowledge": "data_knowledge",
334
+ "generate_response": "generate_response",
335
+ },
336
+ )
337
+ workflow.add_conditional_edges(
338
+ "data_extraction",
339
+ route_after_data_extraction,
340
+ {
341
+ "leading_country": "leading_country",
342
+ "data_knowledge": "data_knowledge",
343
+ "generate_response": "generate_response",
344
+ },
345
+ )
346
+ workflow.add_conditional_edges(
347
+ "leading_country",
348
+ route_after_leading_country,
349
+ {
350
+ "data_knowledge": "data_knowledge",
351
+ "generate_response": "generate_response",
352
+ },
353
+ )
354
+ workflow.add_edge("data_knowledge", "generate_response")
355
+ workflow.add_edge("generate_response", END)
356
+
357
+ return workflow.compile()
358
+
359
+
360
+ def run_user_query(
361
+ app: Any,
362
+ user_query: str,
363
+ conversation_history: list[dict] | None = None,
364
+ ) -> GraphState:
365
+ _log(f"User query: {user_query[:80]}{'...' if len(user_query) > 80 else ''}")
366
+ state = create_initial_state(user_query=user_query, conversation_history=conversation_history)
367
+ result = app.invoke(state)
368
+ _log(f"Done. final_response length={len(result.get('final_response', '') or '')} chars")
369
+ return result