Skip to content

Commit 0140863

Browse files
ScottLinnncopybara-github
authored andcommitted
Add Conversational Analytics support in EDW service layer.
PiperOrigin-RevId: 930873387
1 parent 03249d4 commit 0140863

4 files changed

Lines changed: 555 additions & 0 deletions

File tree

perfkitbenchmarker/edw_service.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,17 @@
1717
directory as a subclass of BaseEdwService.
1818
"""
1919

20+
import csv
21+
import dataclasses
2022
import datetime
23+
import io
2124
import os
2225
from typing import Any, Dict, List
2326

2427
from absl import flags
2528
from absl import logging
29+
from perfkitbenchmarker import data
30+
from perfkitbenchmarker import errors
2631
from perfkitbenchmarker import resource
2732
from perfkitbenchmarker.resources.container_service import kubernetes_cluster
2833

@@ -223,13 +228,41 @@
223228
'Cloud directory of bucket to source ongoing load data '
224229
'for EDW search benchmarks (without rare token).'
225230
)
231+
EDW_CONVERSATIONAL_ANALYTICS_QUESTIONS_FILE = flags.DEFINE_string(
232+
'conversational_analytics_questions_file',
233+
None,
234+
'Filename of the conversational analytics questions CSV (resolved via'
235+
' data_search_paths).',
236+
)
226237
EDW_SEARCH_INDEX_NAME = flags.DEFINE_string(
227238
'edw_search_index_name',
228239
None,
229240
'Name of index for edw search index benchmarks',
230241
)
231242

232243

244+
@dataclasses.dataclass
245+
class ConversationalAnalyticsQuestion:
246+
"""Representation of a Conversational Analytics Question.
247+
248+
Attributes:
249+
question: The natural language question to be asked.
250+
db_id: The database targeted by the question(ecomm or call_center).
251+
ground_truth_sql: The ground truth SQL query for the question.
252+
"""
253+
254+
def __init__(
255+
self,
256+
*,
257+
question: str,
258+
db_id: str,
259+
ground_truth_sql: str,
260+
):
261+
self.question = question
262+
self.db_id = db_id
263+
self.ground_truth_sql = ground_truth_sql
264+
265+
233266
FLAGS = flags.FLAGS
234267

235268
EDW_PYTHON_DRIVER_LIB_FILE = 'edw_python_driver_lib.py'
@@ -483,6 +516,11 @@ def __init__(self, edw_service_spec):
483516
self.supports_wait_on_delete = True
484517
self.client_interface: EdwClientInterface
485518
self.container_cluster: kubernetes_cluster.KubernetesCluster | None = None
519+
self._conversational_analytics_questions: (
520+
list[ConversationalAnalyticsQuestion] | None
521+
) = None
522+
if EDW_CONVERSATIONAL_ANALYTICS_QUESTIONS_FILE.value:
523+
self._LoadConversationalAnalyticsQuestions()
486524

487525
def GetClientInterface(self) -> EdwClientInterface:
488526
"""Gets the active Client Interface."""
@@ -863,6 +901,65 @@ def InjectTokenIntoTable(
863901
"""
864902
raise NotImplementedError
865903

904+
def _LoadConversationalAnalyticsQuestions(self) -> None:
905+
"""Reads the spreadsheet and loads all data into memory."""
906+
if not EDW_CONVERSATIONAL_ANALYTICS_QUESTIONS_FILE.value:
907+
raise ValueError(
908+
'conversational_analytics_questions_file flag must be set.'
909+
)
910+
911+
logging.info(
912+
'Loading dataset from resource: %s',
913+
EDW_CONVERSATIONAL_ANALYTICS_QUESTIONS_FILE.value,
914+
)
915+
csv_path = data.ResourcePath(
916+
EDW_CONVERSATIONAL_ANALYTICS_QUESTIONS_FILE.value
917+
)
918+
with open(csv_path, 'r', encoding='utf-8') as f:
919+
csv_content = f.read()
920+
921+
new_question_list = []
922+
f = io.StringIO(csv_content)
923+
reader = csv.DictReader(f)
924+
for row in reader:
925+
db_id = row.get('DB ID', '').strip()
926+
question = row.get('Question', '').strip()
927+
ground_truth_sql = row.get('Ground Truth SQL', '').strip()
928+
929+
if question and db_id and ground_truth_sql:
930+
q = ConversationalAnalyticsQuestion(
931+
question=question,
932+
db_id=db_id,
933+
ground_truth_sql=ground_truth_sql,
934+
)
935+
new_question_list.append(q)
936+
937+
if not new_question_list:
938+
raise errors.Benchmarks.RunError(
939+
f'Loaded 0 questions from {csv_path}.'
940+
)
941+
942+
self._conversational_analytics_questions = new_question_list
943+
logging.info(
944+
'Loaded %d questions.', len(self._conversational_analytics_questions)
945+
)
946+
947+
def GetConversationalAnalyticsQuestionList(
948+
self,
949+
) -> List[ConversationalAnalyticsQuestion]:
950+
"""Returns the loaded question list."""
951+
if self._conversational_analytics_questions is None:
952+
raise ValueError('Conversational analytics questions not loaded.')
953+
return self._conversational_analytics_questions
954+
955+
def GetConversationalAnalyticsClientInterface(
956+
self,
957+
) -> EdwClientInterface:
958+
"""Returns the Conversational Analytics Client Interface instance."""
959+
raise NotImplementedError(
960+
'Conversational Analytics is not supported for this service.'
961+
)
962+
866963
@staticmethod
867964
def ColsToRows(col_res: dict[str, list[Any]]) -> list[dict[str, Any]]:
868965
"""Converts a dictionary of columns to a list of rows.

perfkitbenchmarker/providers/gcp/bigquery.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import copy
1717
import datetime
18+
import hashlib
1819
import json
1920
import logging
2021
import os
@@ -25,6 +26,7 @@
2526
from absl import flags
2627
from perfkitbenchmarker import data
2728
from perfkitbenchmarker import edw_service
29+
from perfkitbenchmarker import errors
2830
from perfkitbenchmarker import provider_info
2931
from perfkitbenchmarker import vm_util
3032
from perfkitbenchmarker.linux_packages import google_cloud_sdk
@@ -40,6 +42,13 @@
4042
' benchmarking.',
4143
)
4244

45+
BQ_CA_AGENT = flags.DEFINE_string(
46+
'bq_ca_agent',
47+
None,
48+
'The full resource name of the agent to use, e.g. '
49+
'projects/{project}/locations/{location}/dataAgents/{agent_id}',
50+
)
51+
4352
BQ_CLIENT_FILE = 'bq-jdbc-simba-client-1.8-temp-labels.jar'
4453
BQ_PYTHON_CLIENT_FILE = 'bq_python_driver.py'
4554
BQ_PYTHON_CLIENT_DIR = 'edw/bigquery/clients/python'
@@ -508,6 +517,116 @@ def RunQueryWithResults(self, query_name: str) -> str:
508517
return stdout
509518

510519

520+
class ConversationalAnalyticsClientInterface(PythonClientInterface):
521+
"""Conversational Analytics Client Interface subclassing PythonClientInterface."""
522+
523+
def _GetQueryFileName(self, query_name: str) -> str:
524+
"""Generates a filename from a query name."""
525+
sanitized = re.sub(r'[^a-zA-Z0-9_-]', '_', query_name)
526+
# Limit sanitized part to 30 chars
527+
sanitized = sanitized[:30]
528+
# Add hash of full query name to ensure uniqueness
529+
query_hash = hashlib.md5(query_name.encode('utf-8')).hexdigest()[:8]
530+
return f'./{sanitized}_{query_hash}.txt'
531+
532+
def _ParseConversationalAnalyticsResults(
533+
self, results: dict[str, Any], query_name: str
534+
) -> tuple[float, dict[str, Any]]:
535+
"""Parses the results from Conversational Analytics query execution."""
536+
execution_time = results.get('query_wall_time_in_secs', -1.0)
537+
details = results.get('details', {})
538+
query_results = details.get('query_results', {})
539+
540+
# Extract essential fields
541+
text_response = query_results.get('text_response')
542+
generated_sql = query_results.get('generated_sql')
543+
retrieved_data = query_results.get('retrieved_data')
544+
# Fail fast validation
545+
error_msg = None
546+
if not text_response:
547+
error_msg = f"'text_response' is missing or empty. Got: {text_response!r}"
548+
elif not generated_sql:
549+
error_msg = f"'generated_sql' is missing or empty. Got: {generated_sql!r}"
550+
elif not retrieved_data:
551+
error_msg = (
552+
f"'retrieved_data' is missing or empty. Got: {retrieved_data!r}"
553+
)
554+
555+
metadata = {
556+
'question': query_name,
557+
'text_response': text_response or '',
558+
'generated_sql': generated_sql or '',
559+
'retrieved_data': retrieved_data or [],
560+
'thoughts': query_results.get('thoughts', []),
561+
'progress_messages': query_results.get('progress_messages', []),
562+
'time_to_first_token_secs': query_results.get(
563+
'time_to_first_token_secs', 0.0
564+
),
565+
'total_stream_time_secs': query_results.get(
566+
'total_stream_time_secs', 0.0
567+
),
568+
'job_id': details.get('job_id', ''),
569+
}
570+
571+
if error_msg:
572+
logging.warning('Conversational Analytics query failed: %s', error_msg)
573+
metadata['error'] = f'Conversational Analytics query failed: {error_msg}'
574+
return -1.0, metadata
575+
576+
logging.info(
577+
'Conversational Analytics Response: %s',
578+
metadata.get('text_response', ''),
579+
)
580+
return execution_time, metadata
581+
582+
@override
583+
def Prepare(self, package_name: str) -> None:
584+
"""Prepares the client vm by installing geminidataanalytics package and driver."""
585+
super().Prepare(package_name)
586+
587+
# Install BQ CA specific SDK
588+
self.client_vm.RemoteCommand(
589+
'source .venv/bin/activate && pip install'
590+
' google-cloud-geminidataanalytics'
591+
)
592+
593+
# Push Conversational Analytics driver script.
594+
driver_local_path = data.ResourcePath(
595+
os.path.join('edw/bigquery/clients/python', 'bq_ca_driver.py')
596+
)
597+
self.client_vm.PushFile(driver_local_path, 'bq_ca_driver.py')
598+
599+
@override
600+
def ExecuteQuery(
601+
self, query_name: str, print_results: bool = False
602+
) -> tuple[float, dict[str, Any]]:
603+
"""Executes a single conversational analytics question."""
604+
logging.info('Conversational Analytics Question: %s', query_name)
605+
key_file = os.path.basename(FLAGS.gcp_service_account_key_file)
606+
cmd = (
607+
'source .venv/bin/activate && python3 bq_ca_driver.py single '
608+
f'--project={self.project_id} '
609+
f'--agent={BQ_CA_AGENT.value} '
610+
f'--credentials_file={key_file} '
611+
'--print_results '
612+
)
613+
614+
remote_query_file = self._GetQueryFileName(query_name)
615+
# Write question to remote file to handle quotes securely
616+
vm_util.CreateRemoteFile(self.client_vm, query_name, remote_query_file)
617+
cmd += f'--query_file={remote_query_file}'
618+
619+
stdout, _ = self.client_vm.RemoteCommand(cmd)
620+
621+
try:
622+
results = json.loads(stdout)
623+
except ValueError as e:
624+
raise errors.Benchmarks.RunError(
625+
f'Failed to parse Conversational Analytics response: {stdout}'
626+
) from e
627+
return self._ParseConversationalAnalyticsResults(results, query_name)
628+
629+
511630
class Bigquery(edw_service.EdwService):
512631
"""Object representing a Bigquery cluster.
513632
@@ -526,6 +645,14 @@ def __init__(self, edw_service_spec):
526645
project_id, dataset_id = _SplitClusterIdentifier(self.cluster_identifier)
527646
self.client_interface = GetBigQueryClientInterface(project_id, dataset_id)
528647

648+
@override
649+
def GetConversationalAnalyticsClientInterface(
650+
self,
651+
) -> ConversationalAnalyticsClientInterface:
652+
"""Returns the Conversational Analytics Client Interface instance."""
653+
project_id, dataset_id = _SplitClusterIdentifier(self.cluster_identifier)
654+
return ConversationalAnalyticsClientInterface(project_id, dataset_id)
655+
529656
def _Create(self):
530657
"""Create a BigQuery cluster.
531658

0 commit comments

Comments
 (0)