|
17 | 17 | import json |
18 | 18 | import logging |
19 | 19 | import os |
| 20 | +import re |
20 | 21 | import time |
21 | | -from typing import Any, Union |
| 22 | +from typing import Any, Union, Optional |
| 23 | + |
22 | 24 | from google.cloud import bigquery |
23 | 25 | from google.cloud import storage |
24 | 26 | from google.genai._api_client import BaseApiClient |
25 | 27 | import pandas as pd |
| 28 | +import yaml |
| 29 | + |
| 30 | +from . import types |
26 | 31 |
|
27 | 32 | logger = logging.getLogger(__name__) |
28 | 33 |
|
@@ -275,3 +280,248 @@ def load(self, source: Union[str, "pd.DataFrame"]) -> list[dict[str, Any]]: |
275 | 280 | " a valid GCS path with `jsonl` or `csv` suffix, a local" |
276 | 281 | " file path, or a valid BigQuery table URI." |
277 | 282 | ) |
| 283 | + |
| 284 | + |
| 285 | +class LazyLoadedPrebuiltMetric: |
| 286 | + """A proxy object representing a prebuilt metric that will be loaded on demand.""" |
| 287 | + |
| 288 | + _cache: dict[str, types.LLMMetric] = {} |
| 289 | + _base_gcs_path = ( |
| 290 | + "gs://vertex-ai-generative-ai-eval-sdk-resources/metrics/{metric_name}/" |
| 291 | + ) |
| 292 | + |
| 293 | + def __init__(self, name: str, version: Optional[str] = None): |
| 294 | + self.name = name.lower() |
| 295 | + self.version = version or "latest" |
| 296 | + self._resolved_metric: Optional[types.LLMMetric] = None |
| 297 | + |
| 298 | + def _get_latest_version_uri(self, api_client: Any, metric_gcs_dir: str) -> str: |
| 299 | + """Lists files in GCS directory and determines the latest version URI.""" |
| 300 | + gcs_utils = GcsUtils(api_client) |
| 301 | + bucket_name, prefix = gcs_utils.parse_gcs_path(metric_gcs_dir) |
| 302 | + |
| 303 | + blobs = gcs_utils.storage_client.list_blobs(bucket_name, prefix=prefix) |
| 304 | + |
| 305 | + version_files: list[ |
| 306 | + dict[str, Union[list[int], str]] |
| 307 | + ] = [] # {'version_parts': [1,0,0], 'filename': 'v1.0.0.yaml'} |
| 308 | + |
| 309 | + # Regex to capture versions like v1, v1.0, v1.0.0 (supports .yaml and .json) |
| 310 | + # It prioritizes more specific versions (e.g., v1.2.3 over v1.2) |
| 311 | + version_pattern = re.compile( |
| 312 | + r"v(\d+)(?:\.(\d+))?(?:\.(\d+))?\.(yaml|yml|json)$", re.IGNORECASE |
| 313 | + ) |
| 314 | + |
| 315 | + for blob in blobs: |
| 316 | + match = version_pattern.match(os.path.basename(blob.name)) |
| 317 | + if match: |
| 318 | + major = int(match.group(1)) |
| 319 | + minor = int(match.group(2)) if match.group(2) else 0 |
| 320 | + patch = int(match.group(3)) if match.group(3) else 0 |
| 321 | + version_files.append( |
| 322 | + { |
| 323 | + "version_parts": [major, minor, patch], |
| 324 | + "filename": os.path.basename(blob.name), |
| 325 | + } |
| 326 | + ) |
| 327 | + |
| 328 | + if not version_files: |
| 329 | + raise IOError(f"No versioned metric files found in {metric_gcs_dir}") |
| 330 | + |
| 331 | + version_files.sort(key=lambda x: x["version_parts"], reverse=True) |
| 332 | + |
| 333 | + latest_filename = version_files[0]["filename"] |
| 334 | + return os.path.join(metric_gcs_dir, latest_filename) |
| 335 | + |
| 336 | + def _fetch_and_parse(self, api_client: Any) -> types.LLMMetric: |
| 337 | + """Fetches and parses the metric definition from the URI.""" |
| 338 | + metric_gcs_dir = self._base_gcs_path.format(metric_name=self.name) |
| 339 | + uri: str |
| 340 | + if self.version == "latest": |
| 341 | + uri = self._get_latest_version_uri(api_client, metric_gcs_dir) |
| 342 | + resolved_version_match = re.match( |
| 343 | + r"(v\d+(?:\.\d+)*)\.(?:yaml|yml|json)", |
| 344 | + os.path.basename(uri), |
| 345 | + re.IGNORECASE, |
| 346 | + ) |
| 347 | + if resolved_version_match: |
| 348 | + self.version = resolved_version_match.group(1) |
| 349 | + else: |
| 350 | + self.version = os.path.splitext( |
| 351 | + os.path.splitext(os.path.basename(uri))[0] |
| 352 | + )[0] |
| 353 | + else: |
| 354 | + yaml_uri = os.path.join(metric_gcs_dir, f"{self.version}.yaml") |
| 355 | + json_uri = os.path.join(metric_gcs_dir, f"{self.version}.json") |
| 356 | + |
| 357 | + gcs_utils = GcsUtils(api_client) |
| 358 | + try: |
| 359 | + bucket_name, blob_path = gcs_utils.parse_gcs_path(yaml_uri) |
| 360 | + if ( |
| 361 | + gcs_utils.storage_client.bucket(bucket_name) |
| 362 | + .blob(blob_path) |
| 363 | + .exists() |
| 364 | + ): |
| 365 | + uri = yaml_uri |
| 366 | + else: |
| 367 | + bucket_name_json, blob_path_json = gcs_utils.parse_gcs_path( |
| 368 | + json_uri |
| 369 | + ) |
| 370 | + if ( |
| 371 | + gcs_utils.storage_client.bucket(bucket_name_json) |
| 372 | + .blob(blob_path_json) |
| 373 | + .exists() |
| 374 | + ): |
| 375 | + uri = json_uri |
| 376 | + else: |
| 377 | + raise IOError( |
| 378 | + f"Metric file for version '{self.version}' not found as .yaml" |
| 379 | + f" or .json in {metric_gcs_dir}" |
| 380 | + ) |
| 381 | + except Exception as e: |
| 382 | + raise IOError( |
| 383 | + f"Error checking for metric file version '{self.version}' in" |
| 384 | + f" {metric_gcs_dir}: {e}" |
| 385 | + ) from e |
| 386 | + |
| 387 | + logger.info( |
| 388 | + "Fetching predefined metric '%s@%s' from %s...", |
| 389 | + self.name, |
| 390 | + self.version, |
| 391 | + uri, |
| 392 | + ) |
| 393 | + |
| 394 | + gcs_utils = GcsUtils(api_client) |
| 395 | + content_str = gcs_utils.read_file_contents(uri) |
| 396 | + |
| 397 | + file_extension = os.path.splitext(uri)[1].lower() |
| 398 | + data: dict[str, Any] |
| 399 | + if file_extension == ".yaml" or file_extension == ".yml": |
| 400 | + if yaml is None: |
| 401 | + raise ImportError( |
| 402 | + "YAML parsing requires the pyyaml library. Please install it" |
| 403 | + " using 'pip install google-cloud-aiplatform[evaluation]'." |
| 404 | + ) |
| 405 | + data = yaml.safe_load(content_str) |
| 406 | + elif file_extension == ".json": |
| 407 | + data = json.loads(content_str) |
| 408 | + else: |
| 409 | + raise ValueError( |
| 410 | + f"Unsupported file extension for metric config: {file_extension}." |
| 411 | + " Must be .yaml, .yml, or .json" |
| 412 | + ) |
| 413 | + |
| 414 | + if not isinstance(data, dict): |
| 415 | + raise ValueError("Metric config content did not parse into a dictionary.") |
| 416 | + |
| 417 | + if "prompt_template" in data and isinstance(data["prompt_template"], dict): |
| 418 | + data["prompt_template"] = types.MetricPromptBuilder.model_validate( |
| 419 | + data["prompt_template"] |
| 420 | + ) |
| 421 | + |
| 422 | + metric_obj = types.LLMMetric.model_validate(data) |
| 423 | + |
| 424 | + metric_obj._is_predefined = True |
| 425 | + metric_obj._config_source = uri |
| 426 | + metric_obj._version = self.version |
| 427 | + return metric_obj |
| 428 | + |
| 429 | + def resolve(self, api_client: Any) -> types.LLMMetric: |
| 430 | + """Resolves the metric by loading it from the cache or fetching it if necessary.""" |
| 431 | + if self._resolved_metric is None: |
| 432 | + temp_version_for_key = self.version |
| 433 | + |
| 434 | + cache_key = f"{self.name}@{temp_version_for_key}" |
| 435 | + |
| 436 | + if ( |
| 437 | + cache_key in LazyLoadedPrebuiltMetric._cache |
| 438 | + and temp_version_for_key != "latest" |
| 439 | + ): |
| 440 | + self._resolved_metric = LazyLoadedPrebuiltMetric._cache[cache_key] |
| 441 | + logger.debug(f"Metric '{cache_key}' found in cache.") |
| 442 | + else: |
| 443 | + logger.debug( |
| 444 | + f"Metric '{self.name}@{self.version}' not in cache or version is" |
| 445 | + " 'latest'. Fetching..." |
| 446 | + ) |
| 447 | + try: |
| 448 | + fetched_metric = self._fetch_and_parse(api_client) |
| 449 | + final_cache_key = f"{self.name}@{self.version}" |
| 450 | + LazyLoadedPrebuiltMetric._cache[final_cache_key] = fetched_metric |
| 451 | + self._resolved_metric = fetched_metric |
| 452 | + except Exception as e: |
| 453 | + logger.error( |
| 454 | + f"Error loading predefined metric {self.name} (requested version:" |
| 455 | + f" {self.version}): {e}" |
| 456 | + ) |
| 457 | + raise |
| 458 | + return self._resolved_metric |
| 459 | + |
| 460 | + def __call__(self, version: Optional[str] = None) -> "LazyLoadedPrebuiltMetric": |
| 461 | + """Allows setting a specific version.""" |
| 462 | + if version is not None: |
| 463 | + return LazyLoadedPrebuiltMetric(name=self.name, version=version) |
| 464 | + return self |
| 465 | + |
| 466 | + |
| 467 | +class PrebuiltMetricLoader: |
| 468 | + """Provides access to predefined evaluation metrics via attributes. |
| 469 | +
|
| 470 | + This class provides a set of predefined LLM-based metrics (Autorater recipes) |
| 471 | + for evaluation. These metrics are lazily loaded from a GCS repository |
| 472 | + when they are first accessed. |
| 473 | +
|
| 474 | + Example: |
| 475 | + metric = PrebuiltMetric.TEXT_QUALITY |
| 476 | + metric = PrebuiltMetric.TEXT_QUALITY(version="v1") |
| 477 | + """ |
| 478 | + |
| 479 | + def __getattr__(self, name: str) -> LazyLoadedPrebuiltMetric: |
| 480 | + return LazyLoadedPrebuiltMetric(name=name) |
| 481 | + |
| 482 | + @property |
| 483 | + def TEXT_QUALITY(self) -> LazyLoadedPrebuiltMetric: |
| 484 | + return self.__getattr__("TEXT_QUALITY") |
| 485 | + |
| 486 | + @property |
| 487 | + def INSTRUCTION_FOLLOWING(self) -> LazyLoadedPrebuiltMetric: |
| 488 | + return self.__getattr__("INSTRUCTION_FOLLOWING") |
| 489 | + |
| 490 | + @property |
| 491 | + def COHERENCE(self) -> LazyLoadedPrebuiltMetric: |
| 492 | + return self.__getattr__("COHERENCE") |
| 493 | + |
| 494 | + @property |
| 495 | + def FLUENCY(self) -> LazyLoadedPrebuiltMetric: |
| 496 | + return self.__getattr__("FLUENCY") |
| 497 | + |
| 498 | + @property |
| 499 | + def GROUNDEDNESS(self) -> LazyLoadedPrebuiltMetric: |
| 500 | + return self.__getattr__("GROUNDEDNESS") |
| 501 | + |
| 502 | + @property |
| 503 | + def SAFETY(self) -> LazyLoadedPrebuiltMetric: |
| 504 | + return self.__getattr__("SAFETY") |
| 505 | + |
| 506 | + @property |
| 507 | + def VERBOSITY(self) -> LazyLoadedPrebuiltMetric: |
| 508 | + return self.__getattr__("VERBOSITY") |
| 509 | + |
| 510 | + @property |
| 511 | + def SUMMARIZATION_QUALITY(self) -> LazyLoadedPrebuiltMetric: |
| 512 | + return self.__getattr__("SUMMARIZATION_QUALITY") |
| 513 | + |
| 514 | + @property |
| 515 | + def QUESTION_ANSWERING_QUALITY(self) -> LazyLoadedPrebuiltMetric: |
| 516 | + return self.__getattr__("QUESTION_ANSWERING_QUALITY") |
| 517 | + |
| 518 | + @property |
| 519 | + def MULTI_TURN_CHAT_QUALITY(self) -> LazyLoadedPrebuiltMetric: |
| 520 | + return self.__getattr__("MULTI_TURN_CHAT_QUALITY") |
| 521 | + |
| 522 | + @property |
| 523 | + def MULTI_TURN_SAFETY(self) -> LazyLoadedPrebuiltMetric: |
| 524 | + return self.__getattr__("MULTI_TURN_SAFETY") |
| 525 | + |
| 526 | + |
| 527 | +PrebuiltMetric = PrebuiltMetricLoader() |
0 commit comments