diff --git a/conversational-agent-app/app.py b/conversational-agent-app/app.py index d81492ec..55043a09 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 @@ -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"), @@ -420,49 +411,70 @@ 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) - - # Store the conversation_id in chat history + conversation_id, response = genie_query(user_input, user_token, GENIE_SPACE_ID, conversation_id) + + # 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 - - 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 + 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") + data_summary = response.get("data_summary") + msg_content = response.get("content") + + # 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 (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") + ) + + # 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") + ) + + # 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: + 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,92 @@ 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"} + # 3b. Data summary (stats overview of the result) + if data_summary: + content_parts.append( + html.Pre(data_summary, className="data-summary") + ) + + # 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. 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"}) ) - 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 follow_up_question 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([ @@ -751,6 +801,43 @@ def generate_insights(n_clicks, btn_id, chat_history): ], className="insight-wrapper") +# Callback for thumbs up/down feedback +@app.callback( + [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_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 + + 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, 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 feedback: {e}") + return "Failed to send feedback", no_update, no_update + + # Callback to fetch spaces on load # Initialize welcome title and description from space info @app.callback( diff --git a/conversational-agent-app/assets/style.css b/conversational-agent-app/assets/style.css index 57998db5..cfa399ac 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,97 @@ 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; +} + +/* 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 2914a79f..f475299e 100644 --- a/conversational-agent-app/genie_room.py +++ b/conversational-agent-app/genie_room.py @@ -2,10 +2,11 @@ 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 +from databricks.sdk.service.dashboards import GenieFeedbackRating logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -124,41 +125,60 @@ 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) 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 | None, 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) + result["message_id"] = message_id + + 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, "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) -> 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 +186,176 @@ 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 - + 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 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, "data_summary": None, + "content": None, "status": "ERROR", "error": str(e), + "message_id": None, + } + +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) -> 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, 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) + - 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 + + Note: callers typically add a ``message_id`` key to the returned dict. """ - # Check attachments first + result = { + "text_response": None, + "follow_up_question": None, + "sql_query": None, + "sql_description": None, + "dataframe": None, + "data_summary": None, + "content": None, + "status": "OK", + "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", "")) + result["status"] = "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 — separate follow-up questions from real summaries 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 + 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: + 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", [])] -def genie_query(question: str, token: str, space_id: str, conversation_id: str | None = None) -> Tuple[str | None, Union[str, pd.DataFrame], str | None]: + if data_array: + if not columns and len(data_array) > 0: + columns = [f"column_{i}" for i in range(len(data_array[0]))] + + 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["follow_up_question"] 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]]: """ 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, "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