From afcf2a6183ed6a4c2a71944f671d450cfc2242ae Mon Sep 17 00:00:00 2001 From: Wenwen Xie Date: Thu, 12 Mar 2026 00:33:44 -0400 Subject: [PATCH 1/7] Update genie room to return all response parts (text, SQL, data, summary) and polish UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit genie_room.py: process_genie_response now collects all attachment data into a rich dict (text_response, sql_query, sql_description, dataframe, content, error) instead of returning only the first match. Callers updated accordingly. app.py: Renders all response sections in order — text response, SQL description, data table, collapsible SQL code, insight button, and message content. Removed inline styles in favor of CSS classes. style.css: Added .sql-description with blue accent border, polished toggle-query and insight buttons, improved section spacing. Co-Authored-By: Claude Opus 4.6 --- conversational-agent-app/app.py | 172 ++++++++++++---------- conversational-agent-app/assets/style.css | 96 +++++++++--- conversational-agent-app/genie_room.py | 155 +++++++++++-------- 3 files changed, 271 insertions(+), 152 deletions(-) diff --git a/conversational-agent-app/app.py b/conversational-agent-app/app.py index d81492ec..bb8f0bde 100644 --- a/conversational-agent-app/app.py +++ b/conversational-agent-app/app.py @@ -420,49 +420,61 @@ def get_model_response(trigger_data, current_messages, chat_history): if chat_history and len(chat_history) > 0: conversation_id = chat_history[0].get("conversation_id") - conversation_id, response, query_text = genie_query(user_input, user_token, GENIE_SPACE_ID, conversation_id) - + conversation_id, response = genie_query(user_input, user_token, GENIE_SPACE_ID, conversation_id) + # Store the conversation_id in chat history if chat_history and len(chat_history) > 0: chat_history[0]["conversation_id"] = conversation_id - - if isinstance(response, str): - # Escape square brackets to prevent markdown auto-linking - import re - processed_response = response - - # Escape all square brackets to prevent markdown from interpreting them as links - processed_response = processed_response.replace('[', '\\[').replace(']', '\\]') - - # Escape parentheses to prevent markdown from interpreting them as links - processed_response = processed_response.replace('(', '\\(').replace(')', '\\)') - - # Escape angle brackets to prevent markdown from interpreting them as links - processed_response = processed_response.replace('<', '\\<').replace('>', '\\>') - - content = dcc.Markdown(processed_response, className="message-text") - else: - # Data table response - df = pd.DataFrame(response) - - # Store the DataFrame in chat_history for later retrieval by insight button + + # Extract all parts of the response + text_response = response.get("text_response") + sql_query = response.get("sql_query") + sql_description = response.get("sql_description") + df = response.get("dataframe") + msg_content = response.get("content") + error = response.get("error") + + # Build content sections list + content_parts = [] + + # Helper to escape markdown special chars + def escape_md(text): + text = text.replace('[', '\\[').replace(']', '\\]') + text = text.replace('(', '\\(').replace(')', '\\)') + text = text.replace('<', '\\<').replace('>', '\\>') + return text + + # 1. Text response from Genie + if text_response: + content_parts.append( + dcc.Markdown(escape_md(text_response), className="message-text") + ) + + # 2. SQL description / summary + if sql_description: + content_parts.append( + html.Div([ + html.Span("Summary: ", className="sql-description-label"), + html.Span(sql_description) + ], className="sql-description") + ) + + # 3. Data table (if query returned results) + table_uuid = None + if df is not None and not df.empty: + table_uuid = str(uuid.uuid4()) + # Store the DataFrame for insight generation if chat_history and len(chat_history) > 0: - table_uuid = str(uuid.uuid4()) chat_history[0].setdefault('dataframes', {})[table_uuid] = df.to_json(orient='split') else: chat_history = [{"dataframes": {table_uuid: df.to_json(orient='split')}}] - - # Create the table with adjusted styles + data_table = dash_table.DataTable( id=f"table-{len(chat_history)}", data=df.to_dict('records'), columns=[{"name": i, "id": i} for i in df.columns], - - # Export configuration export_format="csv", export_headers="display", - - # Other table properties page_size=10, style_table={ 'display': 'inline-block', @@ -492,54 +504,66 @@ def get_model_response(trigger_data, current_messages, chat_history): page_current=0, page_action='native' ) + content_parts.append( + html.Div([data_table], style={'marginBottom': '20px', 'paddingRight': '5px'}) + ) - # Format SQL query if available - query_section = None - if query_text is not None: - formatted_sql = format_sql_query(query_text) - query_index = f"{len(chat_history)}-{len(current_messages)}" - - query_section = html.Div([ - html.Div([ - html.Button([ - html.Span("Show code", id={"type": "toggle-text", "index": query_index}) - ], - id={"type": "toggle-query", "index": query_index}, - className="toggle-query-button", - n_clicks=0) - ], className="toggle-query-container"), - html.Div([ - html.Pre([ - html.Code(formatted_sql, className="sql-code") - ], className="sql-pre") - ], - id={"type": "query-code", "index": query_index}, - className="query-code-container hidden") - ], id={"type": "query-section", "index": query_index}, className="query-section") - - insight_button = html.Button( - "Generate Insights", - id={"type": "insight-button", "index": table_uuid}, - className="insight-button", - style={"border": "none", "background": "#f0f0f0", "padding": "8px 16px", "borderRadius": "4px", "cursor": "pointer"} + # 4. SQL query toggle section + if sql_query: + formatted_sql = format_sql_query(sql_query) + query_index = f"{len(chat_history)}-{len(current_messages)}" + + query_section = html.Div([ + html.Div([ + html.Button([ + html.Span("Show code", id={"type": "toggle-text", "index": query_index}) + ], + id={"type": "toggle-query", "index": query_index}, + className="toggle-query-button", + n_clicks=0) + ], className="toggle-query-container"), + html.Div([ + html.Pre([ + html.Code(formatted_sql, className="sql-code") + ], className="sql-pre") + ], + id={"type": "query-code", "index": query_index}, + className="query-code-container hidden") + ], id={"type": "query-section", "index": query_index}, className="query-section") + content_parts.append(query_section) + + # 5. Insight button (only if we have data) + if table_uuid: + content_parts.append( + html.Button( + "Generate Insights", + id={"type": "insight-button", "index": table_uuid}, + className="insight-button" + ) + ) + content_parts.append( + dcc.Loading( + id={"type": "insight-loading", "index": table_uuid}, + type="circle", + color="#000000", + children=html.Div(id={"type": "insight-output", "index": table_uuid}) + ) + ) + + # 6. Message content / summary (if not already shown as text_response) + if msg_content and msg_content != text_response: + content_parts.append( + dcc.Markdown(escape_md(msg_content), className="message-text", style={"marginTop": "10px"}) ) - insight_output = dcc.Loading( - id={"type": "insight-loading", "index": table_uuid}, - type="circle", - color="#000000", - children=html.Div(id={"type": "insight-output", "index": table_uuid}) + + # Fallback if nothing was extracted + if not content_parts: + fallback = text_response or msg_content or "No response available" + content_parts.append( + dcc.Markdown(escape_md(fallback), className="message-text") ) - # Create content with table and optional SQL section - content = html.Div([ - html.Div([data_table], style={ - 'marginBottom': '20px', - 'paddingRight': '5px' - }), - query_section if query_section else None, - insight_button, - insight_output, - ]) + content = html.Div(content_parts) # Create bot response bot_response = html.Div([ diff --git a/conversational-agent-app/assets/style.css b/conversational-agent-app/assets/style.css index 57998db5..076ec420 100644 --- a/conversational-agent-app/assets/style.css +++ b/conversational-agent-app/assets/style.css @@ -1003,23 +1003,6 @@ input:disabled, button:disabled { } /* SQL Query Display Styles */ -.toggle-query-container { - margin: 10px 0; - text-align: right; -} - -.toggle-query-button { - background: none; - border: none; - color: #11171C;; - cursor: pointer; - font-size: 12px; - padding: 4px 8px; -} - -.toggle-query-button:hover { - text-decoration: underline; -} .query-code-container { margin: 10px 0; @@ -1479,4 +1462,83 @@ input:disabled, button:disabled { .insight-output strong, .insight-output b{ color: #de2ba5; /* Pinkish-purple */ font-weight: 600; +} + +/* SQL description / summary label */ +.sql-description { + display: flex; + align-items: baseline; + gap: 4px; + padding: 8px 12px; + background-color: #f8f9fb; + border-left: 3px solid #4299E0; + border-radius: 0 6px 6px 0; + margin-bottom: 12px; + line-height: 1.5; + font-size: 13px; + color: #444; +} + +.sql-description-label { + font-weight: 600; + color: #555; + white-space: nowrap; +} + +/* Polished toggle-query button */ +.toggle-query-button { + background: none; + border: 1px solid #dce1e6; + color: #11171C; + cursor: pointer; + font-size: 12px; + padding: 4px 12px; + border-radius: 4px; + transition: all 0.15s ease; +} + +.toggle-query-button:hover { + background-color: #f0f2f5; + border-color: #c0cdd8; + text-decoration: none; +} + +/* Insight button polish */ +.insight-button { + border: 1px solid #dce1e6 !important; + background: #f8f9fb !important; + padding: 8px 16px !important; + border-radius: 6px !important; + cursor: pointer; + font-size: 13px; + font-weight: 500; + color: #11171C; + transition: all 0.15s ease; + margin-top: 4px; +} + +.insight-button:hover { + background-color: #eef1f5 !important; + border-color: #c0cdd8 !important; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); +} + +/* Spacing between response sections */ +.bot-message .message-content > div > .message-text { + margin-bottom: 8px; +} + +.bot-message .message-content > div > .sql-description { + margin-top: 4px; +} + +/* Query section spacing */ +.query-section { + margin-top: 4px; + margin-bottom: 8px; +} + +.toggle-query-container { + margin: 4px 0; + text-align: left; } \ No newline at end of file diff --git a/conversational-agent-app/genie_room.py b/conversational-agent-app/genie_room.py index 2914a79f..5532a575 100644 --- a/conversational-agent-app/genie_room.py +++ b/conversational-agent-app/genie_room.py @@ -2,7 +2,7 @@ import time import os from dotenv import load_dotenv -from typing import Dict, Any, Optional, List, Union, Tuple +from typing import Dict, Any, Tuple import logging from databricks.sdk import WorkspaceClient from databricks.sdk.core import Config @@ -129,36 +129,42 @@ def get_space(self, space_id: str) -> dict: response = self.client.genie.get_space(space_id=space_id) return response.as_dict() -def start_new_conversation(question: str, token: str, space_id: str) -> Tuple[str, Union[str, pd.DataFrame], Optional[str]]: +def start_new_conversation(question: str, token: str, space_id: str) -> Tuple[str, Dict[str, Any]]: """ Start a new conversation with Genie. + Returns: (conversation_id, response_dict) """ client = GenieClient( host=DATABRICKS_HOST, space_id=space_id, token=token ) - + try: # Start a new conversation response = client.start_conversation(question) conversation_id = response["conversation_id"] message_id = response["message_id"] - + # Wait for the message to complete complete_message = client.wait_for_message_completion(conversation_id, message_id) - + # Process the response - result, query_text = process_genie_response(client, conversation_id, message_id, complete_message) - - return conversation_id, result, query_text - + result = process_genie_response(client, conversation_id, message_id, complete_message) + + return conversation_id, result + except Exception as e: - return None, f"Sorry, an error occurred: {str(e)}. Please try again.", None + return None, { + "text_response": f"Sorry, an error occurred: {str(e)}. Please try again.", + "sql_query": None, "sql_description": None, + "dataframe": None, "content": None, "error": str(e), + } -def continue_conversation(conversation_id: str, question: str, token: str, space_id: str) -> Tuple[Union[str, pd.DataFrame], Optional[str]]: +def continue_conversation(conversation_id: str, question: str, token: str, space_id: str) -> Dict[str, Any]: """ Send a follow-up message in an existing conversation. + Returns: response_dict """ logger.info(f"Continuing conversation {conversation_id} with question: {question[:30]}...") client = GenieClient( @@ -166,83 +172,110 @@ def continue_conversation(conversation_id: str, question: str, token: str, space space_id=space_id, token=token ) - + try: # Send follow-up message in existing conversation response = client.send_message(conversation_id, question) message_id = response["message_id"] - + # Wait for the message to complete complete_message = client.wait_for_message_completion(conversation_id, message_id) - + # Process the response - result, query_text = process_genie_response(client, conversation_id, message_id, complete_message) - - return result, query_text - + return process_genie_response(client, conversation_id, message_id, complete_message) + except Exception as e: # Handle specific errors if "429" in str(e) or "Too Many Requests" in str(e): - return "Sorry, the system is currently experiencing high demand. Please try again in a few moments.", None + error_text = "Sorry, the system is currently experiencing high demand. Please try again in a few moments." elif "Conversation not found" in str(e): - return "Sorry, the previous conversation has expired. Please try your query again to start a new conversation.", None + error_text = "Sorry, the previous conversation has expired. Please try your query again to start a new conversation." else: logger.error(f"Error continuing conversation: {str(e)}") - return f"Sorry, an error occurred: {str(e)}", None + error_text = f"Sorry, an error occurred: {str(e)}" + return { + "text_response": error_text, + "sql_query": None, "sql_description": None, + "dataframe": None, "content": None, "error": str(e), + } -def process_genie_response(client, conversation_id, message_id, complete_message) -> Tuple[Union[str, pd.DataFrame], Optional[str]]: +def process_genie_response(client, conversation_id, message_id, complete_message) -> Dict[str, Any]: """ - Process the response from Genie + Process the response from Genie and return all available data. + Returns a dict with keys: + - text_response: str or None (text attachment content) + - sql_query: str or None (generated SQL) + - sql_description: str or None (description of the SQL query) + - dataframe: pd.DataFrame or None (query result data) + - content: str or None (message content / summary) + - error: str or None """ - # Check attachments first + result = { + "text_response": None, + "sql_query": None, + "sql_description": None, + "dataframe": None, + "content": None, + "error": None, + } + + # Extract message-level content (summary / follow-up text) + if "content" in complete_message: + result["content"] = complete_message.get("content", "") + + # Extract error if present + if "error" in complete_message: + result["error"] = str(complete_message.get("error", "")) + + # Process all attachments to collect every piece of data attachments = complete_message.get("attachments", []) for attachment in attachments: attachment_id = attachment.get("attachment_id") - - # If there's text content in the attachment, return it + + # Text attachment if "text" in attachment and "content" in attachment["text"]: - return attachment["text"]["content"], None - - # If there's a query, get the result - elif "query" in attachment: - query_text = attachment.get("query", {}).get("query", "") - query_result = client.get_query_result(conversation_id, message_id, attachment_id) - - data_array = query_result.get('data_array', []) - schema = query_result.get('schema', {}) - columns = [col.get('name') for col in schema.get('columns', [])] - - # If we have data, return as DataFrame - if data_array: - # If no columns from schema, create generic ones - if not columns and data_array and len(data_array) > 0: - columns = [f"column_{i}" for i in range(len(data_array[0]))] - - df = pd.DataFrame(data_array, columns=columns) - return df, query_text - - # If no attachments or no data in attachments, return text content - if 'content' in complete_message: - return complete_message.get('content', ''), None - - return "No response available", None + result["text_response"] = attachment["text"]["content"] + + # Query attachment + if "query" in attachment: + query_info = attachment.get("query", {}) + result["sql_query"] = query_info.get("query") + result["sql_description"] = query_info.get("description") + + if attachment_id and result["sql_query"]: + try: + query_result = client.get_query_result(conversation_id, message_id, attachment_id) + data_array = query_result.get("data_array", []) + schema = query_result.get("schema", {}) + columns = [col.get("name") for col in schema.get("columns", [])] + + if data_array: + if not columns and len(data_array) > 0: + columns = [f"column_{i}" for i in range(len(data_array[0]))] + result["dataframe"] = pd.DataFrame(data_array, columns=columns) + except Exception as e: + logger.warning(f"Could not fetch query result: {e}") -def genie_query(question: str, token: str, space_id: str, conversation_id: str | None = None) -> Tuple[str | None, Union[str, pd.DataFrame], str | None]: + return result + +def genie_query(question: str, token: str, space_id: str, conversation_id: str | None = None) -> Tuple[str | None, Dict[str, Any]]: """ Main entry point for querying Genie. - Returns: (conversation_id, result, query_text) + Returns: (conversation_id, response_dict) """ try: if conversation_id: - # Continue existing conversation - result, query_text = continue_conversation(conversation_id, question, token, space_id) - return conversation_id, result, query_text + result = continue_conversation(conversation_id, question, token, space_id) + return conversation_id, result else: - # Start a new conversation - conversation_id, result, query_text = start_new_conversation(question, token, space_id) - return conversation_id, result, query_text - + conversation_id, result = start_new_conversation(question, token, space_id) + return conversation_id, result + except Exception as e: logger.error(f"Error in conversation: {str(e)}. Please try again.") - return None, f"Sorry, an error occurred: {str(e)}. Please try again.", None + return None, { + "text_response": f"Sorry, an error occurred: {str(e)}. Please try again.", + "sql_query": None, "sql_description": None, + "dataframe": None, "content": None, "error": str(e), + } From 25e8eb6a592e740c0f9a0bc80b90dd9397214c3b Mon Sep 17 00:00:00 2001 From: Wenwen Xie Date: Thu, 12 Mar 2026 00:54:46 -0400 Subject: [PATCH 2/7] Add data summary, numeric conversion, and fix response layout ordering genie_room.py: Add _generate_data_summary() for DataFrame stats, pd.to_numeric conversion for numeric columns, data_summary and status fields in response dict. app.py: Show text response at top only when no query data; show follow-up question below data summary; suppress echoed user question from msg_content; render data_summary stats block below table. style.css: Add .data-summary styling for stats overview block. Co-Authored-By: Claude Opus 4.6 --- conversational-agent-app/app.py | 21 ++++++++-- conversational-agent-app/assets/style.css | 14 +++++++ conversational-agent-app/genie_room.py | 47 ++++++++++++++++++++--- 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/conversational-agent-app/app.py b/conversational-agent-app/app.py index bb8f0bde..34b4fb53 100644 --- a/conversational-agent-app/app.py +++ b/conversational-agent-app/app.py @@ -431,6 +431,7 @@ def get_model_response(trigger_data, current_messages, chat_history): sql_query = response.get("sql_query") sql_description = response.get("sql_description") df = response.get("dataframe") + data_summary = response.get("data_summary") msg_content = response.get("content") error = response.get("error") @@ -444,8 +445,8 @@ def escape_md(text): text = text.replace('<', '\\<').replace('>', '\\>') return text - # 1. Text response from Genie - if text_response: + # 1. Text response from Genie (show at top only when there's no query data) + if text_response and df is None: content_parts.append( dcc.Markdown(escape_md(text_response), className="message-text") ) @@ -508,6 +509,18 @@ def escape_md(text): html.Div([data_table], style={'marginBottom': '20px', 'paddingRight': '5px'}) ) + # 3b. Data summary (stats overview of the result) + if data_summary: + content_parts.append( + html.Pre(data_summary, className="data-summary") + ) + + # 3c. Follow-up question from Genie (shown below data when query data exists) + if text_response and df is not None: + content_parts.append( + dcc.Markdown(escape_md(text_response), className="message-text") + ) + # 4. SQL query toggle section if sql_query: formatted_sql = format_sql_query(sql_query) @@ -550,8 +563,8 @@ def escape_md(text): ) ) - # 6. Message content / summary (if not already shown as text_response) - if msg_content and msg_content != text_response: + # 6. Message content (only when it's not the echoed user question and no query data) + if msg_content and msg_content != text_response and msg_content != user_input and df is None: content_parts.append( dcc.Markdown(escape_md(msg_content), className="message-text", style={"marginTop": "10px"}) ) diff --git a/conversational-agent-app/assets/style.css b/conversational-agent-app/assets/style.css index 076ec420..cfa399ac 100644 --- a/conversational-agent-app/assets/style.css +++ b/conversational-agent-app/assets/style.css @@ -1541,4 +1541,18 @@ input:disabled, button:disabled { .toggle-query-container { margin: 4px 0; text-align: left; +} + +/* Data summary (stats overview below table) */ +.data-summary { + background-color: #f8f9fb; + border: 1px solid #e9ecef; + border-radius: 6px; + padding: 12px 16px; + margin-bottom: 12px; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 12px; + line-height: 1.6; + color: #444; + white-space: pre-wrap; } \ No newline at end of file diff --git a/conversational-agent-app/genie_room.py b/conversational-agent-app/genie_room.py index 5532a575..f2181963 100644 --- a/conversational-agent-app/genie_room.py +++ b/conversational-agent-app/genie_room.py @@ -158,7 +158,8 @@ def start_new_conversation(question: str, token: str, space_id: str) -> Tuple[st return None, { "text_response": f"Sorry, an error occurred: {str(e)}. Please try again.", "sql_query": None, "sql_description": None, - "dataframe": None, "content": None, "error": str(e), + "dataframe": None, "data_summary": None, + "content": None, "status": "ERROR", "error": str(e), } def continue_conversation(conversation_id: str, question: str, token: str, space_id: str) -> Dict[str, Any]: @@ -196,18 +197,35 @@ def continue_conversation(conversation_id: str, question: str, token: str, space return { "text_response": error_text, "sql_query": None, "sql_description": None, - "dataframe": None, "content": None, "error": str(e), + "dataframe": None, "data_summary": None, + "content": None, "status": "ERROR", "error": str(e), } +def _generate_data_summary(df: pd.DataFrame) -> str: + """Generate a brief summary of a DataFrame's contents.""" + lines = [f"Rows: {len(df)}, Columns: {len(df.columns)}"] + + numeric_cols = df.select_dtypes(include=['number']).columns + for col in numeric_cols[:5]: + lines.append(f" {col}: min={df[col].min()}, max={df[col].max()}, mean={df[col].mean():.2f}") + + if len(numeric_cols) > 5: + lines.append(f" ... and {len(numeric_cols) - 5} more numeric columns") + + return "\n".join(lines) + + def process_genie_response(client, conversation_id, message_id, complete_message) -> Dict[str, Any]: """ - Process the response from Genie and return all available data. + Process the response from Genie, collecting ALL available data. Returns a dict with keys: - text_response: str or None (text attachment content) - sql_query: str or None (generated SQL) - sql_description: str or None (description of the SQL query) - dataframe: pd.DataFrame or None (query result data) + - data_summary: str or None (brief stats summary of the data) - content: str or None (message content / summary) + - status: str ("OK" or "ERROR") - error: str or None """ result = { @@ -215,7 +233,9 @@ def process_genie_response(client, conversation_id, message_id, complete_message "sql_query": None, "sql_description": None, "dataframe": None, + "data_summary": None, "content": None, + "status": "OK", "error": None, } @@ -226,6 +246,7 @@ def process_genie_response(client, conversation_id, message_id, complete_message # Extract error if present if "error" in complete_message: result["error"] = str(complete_message.get("error", "")) + result["status"] = "ERROR" # Process all attachments to collect every piece of data attachments = complete_message.get("attachments", []) @@ -252,10 +273,25 @@ def process_genie_response(client, conversation_id, message_id, complete_message if data_array: if not columns and len(data_array) > 0: columns = [f"column_{i}" for i in range(len(data_array[0]))] - result["dataframe"] = pd.DataFrame(data_array, columns=columns) + + df = pd.DataFrame(data_array, columns=columns) + + # Try to convert numeric columns + for col in df.columns: + try: + df[col] = pd.to_numeric(df[col]) + except (ValueError, TypeError): + pass + + result["dataframe"] = df + result["data_summary"] = _generate_data_summary(df) except Exception as e: logger.warning(f"Could not fetch query result: {e}") + # If nothing was populated, set a default text + if result["text_response"] is None and result["dataframe"] is None: + result["text_response"] = "No response available" + return result def genie_query(question: str, token: str, space_id: str, conversation_id: str | None = None) -> Tuple[str | None, Dict[str, Any]]: @@ -276,6 +312,7 @@ def genie_query(question: str, token: str, space_id: str, conversation_id: str | return None, { "text_response": f"Sorry, an error occurred: {str(e)}. Please try again.", "sql_query": None, "sql_description": None, - "dataframe": None, "content": None, "error": str(e), + "dataframe": None, "data_summary": None, + "content": None, "status": "ERROR", "error": str(e), } From f861797e6350e65a93f81a2b51873ebe8078bcbd Mon Sep 17 00:00:00 2001 From: Wenwen Xie Date: Thu, 12 Mar 2026 00:58:41 -0400 Subject: [PATCH 3/7] Move Genie text summary above the data table, below the description box Co-Authored-By: Claude Opus 4.6 --- conversational-agent-app/app.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/conversational-agent-app/app.py b/conversational-agent-app/app.py index 34b4fb53..76f0f2bd 100644 --- a/conversational-agent-app/app.py +++ b/conversational-agent-app/app.py @@ -460,6 +460,12 @@ def escape_md(text): ], className="sql-description") ) + # 2b. Genie text summary (shown below description box, above table) + if text_response and df is not None: + content_parts.append( + dcc.Markdown(escape_md(text_response), className="message-text") + ) + # 3. Data table (if query returned results) table_uuid = None if df is not None and not df.empty: @@ -515,12 +521,6 @@ def escape_md(text): html.Pre(data_summary, className="data-summary") ) - # 3c. Follow-up question from Genie (shown below data when query data exists) - if text_response and df is not None: - content_parts.append( - dcc.Markdown(escape_md(text_response), className="message-text") - ) - # 4. SQL query toggle section if sql_query: formatted_sql = format_sql_query(sql_query) From c2bf026653457598407f9aaa5c501ab387c98d0b Mon Sep 17 00:00:00 2001 From: Wenwen Xie Date: Thu, 12 Mar 2026 01:07:45 -0400 Subject: [PATCH 4/7] Add thumbs up/down feedback and separate follow-up questions from summaries Add user feedback buttons (thumbs up/down) that send ratings to the Genie space via the SDK. Separate text attachments by purpose so follow-up questions no longer overwrite the real text summary in responses. Co-Authored-By: Claude Opus 4.6 --- conversational-agent-app/app.py | 75 ++++++++++++++++++++++++-- conversational-agent-app/genie_room.py | 48 +++++++++++++++-- 2 files changed, 115 insertions(+), 8 deletions(-) diff --git a/conversational-agent-app/app.py b/conversational-agent-app/app.py index 76f0f2bd..3b7bf9c6 100644 --- a/conversational-agent-app/app.py +++ b/conversational-agent-app/app.py @@ -2,7 +2,7 @@ from dash import html, dcc, Input, Output, State, callback, ALL, MATCH, callback_context, no_update, clientside_callback, dash_table import dash_bootstrap_components as dbc import json -from genie_room import genie_query +from genie_room import genie_query, send_feedback import pandas as pd import os from dotenv import load_dotenv @@ -422,12 +422,15 @@ def get_model_response(trigger_data, current_messages, chat_history): conversation_id, response = genie_query(user_input, user_token, GENIE_SPACE_ID, conversation_id) - # Store the conversation_id in chat history + # Store the conversation_id and message_id in chat history + message_id = response.get("message_id") if chat_history and len(chat_history) > 0: chat_history[0]["conversation_id"] = conversation_id + chat_history[0]["last_message_id"] = message_id # Extract all parts of the response text_response = response.get("text_response") + follow_up_question = response.get("follow_up_question") sql_query = response.get("sql_query") sql_description = response.get("sql_description") df = response.get("dataframe") @@ -563,7 +566,27 @@ def escape_md(text): ) ) - # 6. Message content (only when it's not the echoed user question and no query data) + # 6. Thumbs up/down feedback buttons + if message_id and conversation_id: + feedback_id = f"{conversation_id}|{message_id}" + content_parts.append( + html.Div([ + html.Button( + id={"type": "thumbs-up", "index": feedback_id}, + className="thumbs-up-button", + n_clicks=0 + ), + html.Button( + id={"type": "thumbs-down", "index": feedback_id}, + className="thumbs-down-button", + n_clicks=0 + ), + html.Span("", id={"type": "feedback-status", "index": feedback_id}, + style={"fontSize": "12px", "color": "#5F7281", "marginLeft": "8px"}) + ], className="message-actions") + ) + + # 7. Message content (only when it's not the echoed user question and no query data) if msg_content and msg_content != text_response and msg_content != user_input and df is None: content_parts.append( dcc.Markdown(escape_md(msg_content), className="message-text", style={"marginTop": "10px"}) @@ -571,7 +594,7 @@ def escape_md(text): # Fallback if nothing was extracted if not content_parts: - fallback = text_response or msg_content or "No response available" + fallback = text_response or follow_up_question or msg_content or "No response available" content_parts.append( dcc.Markdown(escape_md(fallback), className="message-text") ) @@ -788,6 +811,50 @@ def generate_insights(n_clicks, btn_id, chat_history): ], className="insight-wrapper") +# Callback for thumbs up feedback +@app.callback( + Output({"type": "feedback-status", "index": MATCH}, "children", allow_duplicate=True), + Input({"type": "thumbs-up", "index": MATCH}, "n_clicks"), + State({"type": "thumbs-up", "index": MATCH}, "id"), + prevent_initial_call=True +) +def handle_thumbs_up(n_clicks, btn_id): + if not n_clicks: + return no_update + feedback_id = btn_id["index"] + conversation_id, message_id = feedback_id.split("|") + try: + headers = request.headers + user_token = headers.get('X-Forwarded-Access-Token') + success = send_feedback(conversation_id, message_id, "POSITIVE", user_token, GENIE_SPACE_ID) + return "Thanks for your feedback!" if success else "Failed to send feedback" + except Exception as e: + logger.error(f"Error sending positive feedback: {e}") + return "Failed to send feedback" + + +# Callback for thumbs down feedback +@app.callback( + Output({"type": "feedback-status", "index": MATCH}, "children", allow_duplicate=True), + Input({"type": "thumbs-down", "index": MATCH}, "n_clicks"), + State({"type": "thumbs-down", "index": MATCH}, "id"), + prevent_initial_call=True +) +def handle_thumbs_down(n_clicks, btn_id): + if not n_clicks: + return no_update + feedback_id = btn_id["index"] + conversation_id, message_id = feedback_id.split("|") + try: + headers = request.headers + user_token = headers.get('X-Forwarded-Access-Token') + success = send_feedback(conversation_id, message_id, "NEGATIVE", user_token, GENIE_SPACE_ID) + return "Thanks for your feedback!" if success else "Failed to send feedback" + except Exception as e: + logger.error(f"Error sending negative feedback: {e}") + return "Failed to send feedback" + + # Callback to fetch spaces on load # Initialize welcome title and description from space info @app.callback( diff --git a/conversational-agent-app/genie_room.py b/conversational-agent-app/genie_room.py index f2181963..b4f43af7 100644 --- a/conversational-agent-app/genie_room.py +++ b/conversational-agent-app/genie_room.py @@ -6,6 +6,7 @@ import logging from databricks.sdk import WorkspaceClient from databricks.sdk.core import Config +from databricks.sdk.service.dashboards import GenieFeedbackRating logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -124,6 +125,16 @@ def wait_for_message_completion(self, conversation_id: str, message_id: str, tim raise TimeoutError(f"Message processing timed out after {timeout} seconds") + def send_feedback(self, conversation_id: str, message_id: str, rating: str) -> None: + """Send feedback (thumbs up/down) for a message.""" + rating_enum = GenieFeedbackRating(rating) + self.client.genie.send_message_feedback( + space_id=self.space_id, + conversation_id=conversation_id, + message_id=message_id, + rating=rating_enum + ) + def get_space(self, space_id: str) -> dict: """Get details of a specific Genie space.""" response = self.client.genie.get_space(space_id=space_id) @@ -151,6 +162,7 @@ def start_new_conversation(question: str, token: str, space_id: str) -> Tuple[st # Process the response result = process_genie_response(client, conversation_id, message_id, complete_message) + result["message_id"] = message_id return conversation_id, result @@ -160,6 +172,7 @@ def start_new_conversation(question: str, token: str, space_id: str) -> Tuple[st "sql_query": None, "sql_description": None, "dataframe": None, "data_summary": None, "content": None, "status": "ERROR", "error": str(e), + "message_id": None, } def continue_conversation(conversation_id: str, question: str, token: str, space_id: str) -> Dict[str, Any]: @@ -183,7 +196,9 @@ def continue_conversation(conversation_id: str, question: str, token: str, space complete_message = client.wait_for_message_completion(conversation_id, message_id) # Process the response - return process_genie_response(client, conversation_id, message_id, complete_message) + result = process_genie_response(client, conversation_id, message_id, complete_message) + result["message_id"] = message_id + return result except Exception as e: # Handle specific errors @@ -199,6 +214,7 @@ def continue_conversation(conversation_id: str, question: str, token: str, space "sql_query": None, "sql_description": None, "dataframe": None, "data_summary": None, "content": None, "status": "ERROR", "error": str(e), + "message_id": None, } def _generate_data_summary(df: pd.DataFrame) -> str: @@ -230,6 +246,7 @@ def process_genie_response(client, conversation_id, message_id, complete_message """ result = { "text_response": None, + "follow_up_question": None, "sql_query": None, "sql_description": None, "dataframe": None, @@ -253,9 +270,13 @@ def process_genie_response(client, conversation_id, message_id, complete_message for attachment in attachments: attachment_id = attachment.get("attachment_id") - # Text attachment + # Text attachment — separate follow-up questions from real summaries if "text" in attachment and "content" in attachment["text"]: - result["text_response"] = attachment["text"]["content"] + text_info = attachment["text"] + if text_info.get("purpose") == "FOLLOW_UP_QUESTION": + result["follow_up_question"] = text_info["content"] + else: + result["text_response"] = text_info["content"] # Query attachment if "query" in attachment: @@ -289,7 +310,7 @@ def process_genie_response(client, conversation_id, message_id, complete_message logger.warning(f"Could not fetch query result: {e}") # If nothing was populated, set a default text - if result["text_response"] is None and result["dataframe"] is None: + if result["text_response"] is None and result["follow_up_question"] is None and result["dataframe"] is None: result["text_response"] = "No response available" return result @@ -314,5 +335,24 @@ def genie_query(question: str, token: str, space_id: str, conversation_id: str | "sql_query": None, "sql_description": None, "dataframe": None, "data_summary": None, "content": None, "status": "ERROR", "error": str(e), + "message_id": None, } +def send_feedback(conversation_id: str, message_id: str, rating: str, token: str, space_id: str) -> bool: + """ + Send thumbs up/down feedback for a Genie message. + rating: "POSITIVE" or "NEGATIVE" + Returns True on success, False on failure. + """ + try: + client = GenieClient( + host=DATABRICKS_HOST, + space_id=space_id, + token=token + ) + client.send_feedback(conversation_id, message_id, rating) + return True + except Exception as e: + logger.error(f"Error sending feedback: {str(e)}") + return False + From 781f8472c8a6c0afd261785a5e2db0f6f76af25f Mon Sep 17 00:00:00 2001 From: Wenwen Xie Date: Thu, 12 Mar 2026 09:29:15 -0400 Subject: [PATCH 5/7] Highlight active feedback button with color on click Combine thumbs up/down into a single callback that toggles the active CSS class on the clicked button, providing visual feedback to the user. Co-Authored-By: Claude Opus 4.6 --- conversational-agent-app/app.py | 55 ++++++++++++++------------------- 1 file changed, 24 insertions(+), 31 deletions(-) diff --git a/conversational-agent-app/app.py b/conversational-agent-app/app.py index 3b7bf9c6..7e356ef9 100644 --- a/conversational-agent-app/app.py +++ b/conversational-agent-app/app.py @@ -811,48 +811,41 @@ def generate_insights(n_clicks, btn_id, chat_history): ], className="insight-wrapper") -# Callback for thumbs up feedback +# Callback for thumbs up/down feedback @app.callback( - Output({"type": "feedback-status", "index": MATCH}, "children", allow_duplicate=True), - Input({"type": "thumbs-up", "index": MATCH}, "n_clicks"), - State({"type": "thumbs-up", "index": MATCH}, "id"), + [Output({"type": "feedback-status", "index": MATCH}, "children"), + Output({"type": "thumbs-up", "index": MATCH}, "className"), + Output({"type": "thumbs-down", "index": MATCH}, "className")], + [Input({"type": "thumbs-up", "index": MATCH}, "n_clicks"), + Input({"type": "thumbs-down", "index": MATCH}, "n_clicks")], + [State({"type": "thumbs-up", "index": MATCH}, "id")], prevent_initial_call=True ) -def handle_thumbs_up(n_clicks, btn_id): - if not n_clicks: - return no_update - feedback_id = btn_id["index"] - conversation_id, message_id = feedback_id.split("|") - try: - headers = request.headers - user_token = headers.get('X-Forwarded-Access-Token') - success = send_feedback(conversation_id, message_id, "POSITIVE", user_token, GENIE_SPACE_ID) - return "Thanks for your feedback!" if success else "Failed to send feedback" - except Exception as e: - logger.error(f"Error sending positive feedback: {e}") - return "Failed to send feedback" +def handle_feedback(thumbs_up_clicks, thumbs_down_clicks, btn_id): + ctx = callback_context + if not ctx.triggered: + return no_update, no_update, no_update + trigger_id = ctx.triggered[0]["prop_id"] + is_positive = "thumbs-up" in trigger_id -# Callback for thumbs down feedback -@app.callback( - Output({"type": "feedback-status", "index": MATCH}, "children", allow_duplicate=True), - Input({"type": "thumbs-down", "index": MATCH}, "n_clicks"), - State({"type": "thumbs-down", "index": MATCH}, "id"), - prevent_initial_call=True -) -def handle_thumbs_down(n_clicks, btn_id): - if not n_clicks: - return no_update feedback_id = btn_id["index"] conversation_id, message_id = feedback_id.split("|") + rating = "POSITIVE" if is_positive else "NEGATIVE" + try: headers = request.headers user_token = headers.get('X-Forwarded-Access-Token') - success = send_feedback(conversation_id, message_id, "NEGATIVE", user_token, GENIE_SPACE_ID) - return "Thanks for your feedback!" if success else "Failed to send feedback" + success = send_feedback(conversation_id, message_id, rating, user_token, GENIE_SPACE_ID) + if success: + if is_positive: + return "Thanks for your feedback!", "thumbs-up-button active", "thumbs-down-button" + else: + return "Thanks for your feedback!", "thumbs-up-button", "thumbs-down-button active" + return "Failed to send feedback", no_update, no_update except Exception as e: - logger.error(f"Error sending negative feedback: {e}") - return "Failed to send feedback" + logger.error(f"Error sending feedback: {e}") + return "Failed to send feedback", no_update, no_update # Callback to fetch spaces on load From 1ee14edf4a00e17dec1113c8497ba9049d1c021e Mon Sep 17 00:00:00 2001 From: Wenwen Xie Date: Thu, 12 Mar 2026 09:30:09 -0400 Subject: [PATCH 6/7] Remove logout button from top navigation Co-Authored-By: Claude Opus 4.6 --- conversational-agent-app/app.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/conversational-agent-app/app.py b/conversational-agent-app/app.py index 7e356ef9..dcc51daf 100644 --- a/conversational-agent-app/app.py +++ b/conversational-agent-app/app.py @@ -94,15 +94,6 @@ ], className="nav-center"), html.Div([ html.Div("Y", id="top-nav-avatar", className="user-avatar"), - html.A( - html.Button( - "Logout", - id="logout-button", - className="logout-button" - ), - href=f"{os.getenv('DATABRICKS_APP_URL')}", - className="logout-link" - ) ], className="nav-right") ], className="top-nav"), From 6c9794af3888bb3968c6bb953e344068834eae4a Mon Sep 17 00:00:00 2001 From: Wenwen Xie Date: Thu, 12 Mar 2026 10:40:12 -0400 Subject: [PATCH 7/7] Minor cleanup: remove unused variable, fix type hint, update docstring Co-Authored-By: Claude Opus 4.6 --- conversational-agent-app/app.py | 1 - conversational-agent-app/genie_room.py | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/conversational-agent-app/app.py b/conversational-agent-app/app.py index dcc51daf..55043a09 100644 --- a/conversational-agent-app/app.py +++ b/conversational-agent-app/app.py @@ -427,7 +427,6 @@ def get_model_response(trigger_data, current_messages, chat_history): df = response.get("dataframe") data_summary = response.get("data_summary") msg_content = response.get("content") - error = response.get("error") # Build content sections list content_parts = [] diff --git a/conversational-agent-app/genie_room.py b/conversational-agent-app/genie_room.py index b4f43af7..f475299e 100644 --- a/conversational-agent-app/genie_room.py +++ b/conversational-agent-app/genie_room.py @@ -140,7 +140,7 @@ def get_space(self, space_id: str) -> dict: response = self.client.genie.get_space(space_id=space_id) return response.as_dict() -def start_new_conversation(question: str, token: str, space_id: str) -> Tuple[str, Dict[str, Any]]: +def start_new_conversation(question: str, token: str, space_id: str) -> Tuple[str | None, Dict[str, Any]]: """ Start a new conversation with Genie. Returns: (conversation_id, response_dict) @@ -236,6 +236,7 @@ def process_genie_response(client, conversation_id, message_id, complete_message Process the response from Genie, collecting ALL available data. Returns a dict with keys: - text_response: str or None (text attachment content) + - follow_up_question: str or None (suggested follow-up question) - sql_query: str or None (generated SQL) - sql_description: str or None (description of the SQL query) - dataframe: pd.DataFrame or None (query result data) @@ -243,6 +244,8 @@ def process_genie_response(client, conversation_id, message_id, complete_message - content: str or None (message content / summary) - status: str ("OK" or "ERROR") - error: str or None + + Note: callers typically add a ``message_id`` key to the returned dict. """ result = { "text_response": None,