Files
2026-07-13 13:30:30 +08:00

482 lines
19 KiB
Python

## Copyright 2025 Google LLC
## Licensed under the Apache License, Version 2.0 (the "License");
## you may not use this file except in compliance with the License.
## You may obtain a copy of the License at
## https://www.apache.org/licenses/LICENSE-2.0
## Unless required by applicable law or agreed to in writing, software
## distributed under the License is distributed on an "AS IS" BASIS,
## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
## See the License for the specific language
import json
import logging
import os
import pandas as pd
import streamlit as st
from dotenv import load_dotenv
from google.cloud import storage
from src import vapo_lib
# Load environment variables
load_dotenv("src/.env")
# Configure logging to the console
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# --- Constants ---
BASE_OPTIMIZATION_PREFIX = "optimization/"
OPTIMIZATION_JOBS_SUBDIR = "optimization_jobs/"
from google.cloud import aiplatform
def list_custom_training_jobs(project_id: str, location: str):
"""Lists all custom training jobs and their statuses in a given project and location.
Args:
project_id: The Google Cloud project ID.
location: The region for the Agent Platform jobs, e.g., "us-central1".
Returns:
A list of dictionaries, where each dictionary contains details of a custom job.
"""
# Initialize the Agent Platform client
# The API endpoint is determined by the location
client_options = {"api_endpoint": f"{location}-aiplatform.googleapis.com"}
client = aiplatform.gapic.JobServiceClient(client_options=client_options)
# The parent resource path format
parent = f"projects/{project_id}/locations/{location}"
# Make the API request to list custom jobs
response = client.list_custom_jobs(parent=parent)
# Process the response and format the output
jobs_list = []
print(f"Fetching jobs from project '{project_id}' in '{location}'...")
for job in response:
job_info = {
"display_name": job.display_name,
"name": job.name,
"status": job.state.name, # .name gets the string representation of the enum
}
jobs_list.append(job_info)
print(f"Found {len(jobs_list)} jobs.")
return jobs_list
# --- Example Usage ---
if __name__ == "__main__":
# Replace with your project ID and desired location
PROJECT_ID = os.getenv("PROJECT_ID")
LOCATION = os.getenv("LOCATION")
# Ensure you have authenticated with Google Cloud CLI:
# gcloud auth application-default login
# And have the necessary permissions (e.g., "Agent Platform User" role)
try:
all_jobs = list_custom_training_jobs(project_id=PROJECT_ID, location=LOCATION)
# Print the results
if all_jobs:
print("\n--- Job Statuses ---")
for job in all_jobs:
print(f" - Name: {job['display_name']:<40} Status: {job['status']}")
print("--------------------\n")
else:
print("No custom jobs found.")
except Exception as e:
print(
"\nAn error occurred. Please ensure your project ID and location are correct,"
)
print(f"and that you have authenticated correctly. Error: {e}")
def safe_json_loads(s):
"""Safely loads a JSON string, returning the original value on failure."""
if not isinstance(s, str):
return s
try:
return json.loads(s)
except (json.JSONDecodeError, TypeError):
return s
@st.cache_data(ttl=300)
def list_gcs_directories(
bucket_name: str, prefix: str, _storage_client: storage.Client
) -> list[str]:
"""Lists 'directories' in GCS under a given prefix.
A 'directory' is inferred from the common prefixes of objects.
Caches the result for 5 minutes to improve performance.
"""
if not bucket_name:
st.warning("BUCKET environment variable is not set.")
return []
if not _storage_client:
st.warning("Storage client is not initialized.")
return []
bucket = _storage_client.bucket(bucket_name)
retrieved_prefixes = set()
try:
for page in bucket.list_blobs(prefix=prefix, delimiter="/").pages:
retrieved_prefixes.update(page.prefixes)
# The retrieved prefixes are the "subdirectories".
# e.g., for prefix 'optimization/', a retrieved prefix might be 'optimization/op_id/'.
# We want to extract just 'op_id'.
dir_names = []
for p in retrieved_prefixes:
name = p.replace(prefix, "").strip("/")
if name:
dir_names.append(name)
return sorted(set(dir_names))
except Exception as e:
st.error(
f"Error listing GCS directories under gs://{bucket_name}/{prefix}: {e}"
)
logger.error(
f"Error listing GCS directories under gs://{bucket_name}/{prefix}: {e}",
exc_info=True,
)
return []
def _display_interactive_results(results_ui: vapo_lib.ResultsUI) -> None:
"""Processes results from a VAPO run and displays them in an interactive
Streamlit UI with tabs for each prompt version.
"""
try:
if (
not hasattr(results_ui, "templates")
or not results_ui.templates
or not hasattr(results_ui, "eval_results")
):
logger.info(
"ResultsUI object does not have 'templates' or 'eval_results', or templates list is empty. Falling back."
)
st.info(
"No completed runs found yet in this directory. The evaluation might still be running or failed to produce results."
)
else:
processed_results_for_tabs = []
for i, template_summary_df in enumerate(results_ui.templates):
if (
not isinstance(template_summary_df, pd.DataFrame)
or template_summary_df.empty
):
logger.warning(
f"Template summary data at index {i} is not a non-empty DataFrame. Skipping."
)
continue
# Get the detailed results to perform the custom calculation
detailed_eval_df = pd.DataFrame()
if i < len(results_ui.eval_results) and isinstance(
results_ui.eval_results[i], pd.DataFrame
):
detailed_eval_df = results_ui.eval_results[i]
# Add a custom exact_match calculation. This is more robust than simple
# string comparison as it handles differences in JSON key order and whitespace.
if (
not detailed_eval_df.empty
and "ground_truth" in detailed_eval_df.columns
and "reference" in detailed_eval_df.columns
):
# Parse the JSON strings into Python objects before comparing.
parsed_ground_truths = detailed_eval_df["ground_truth"].apply(
safe_json_loads
)
parsed_references = detailed_eval_df["reference"].apply(
safe_json_loads
)
# Create a boolean series for the comparison
is_match = parsed_ground_truths.eq(parsed_references)
# Map boolean to 'yes'/'no' for display in the detailed table
detailed_eval_df["calculated_exact_match"] = is_match.map(
{True: "yes", False: "no"}
)
# Calculate the mean from the boolean series for the summary metric
new_exact_match_mean = is_match.mean()
template_summary_df["metrics.calculated_exact_match/mean"] = (
new_exact_match_mean
)
prompt_text = "Prompt text not found in template data."
if "prompt" in template_summary_df.columns:
prompt_text = template_summary_df["prompt"].iloc[0]
else:
logger.warning(
f"Column 'prompt' not found in template_summary_df at index {i}."
)
# Determine the primary score and build the tab name.
primary_score_label = "Score"
primary_score_value = "N/A"
if "metrics.calculated_exact_match/mean" in template_summary_df.columns:
primary_score_label = "Calculated Exact Match"
primary_score_value = template_summary_df[
"metrics.calculated_exact_match/mean"
].iloc[0]
else:
# Fallback to the first available metric
mean_metric_columns = [
col
for col in template_summary_df.columns
if col.startswith("metrics.") and "/mean" in col
]
if mean_metric_columns:
first_metric_col = mean_metric_columns[0]
primary_score_label = (
first_metric_col.replace("metrics.", "")
.replace("/mean", "")
.replace("_", " ")
.title()
)
primary_score_value = template_summary_df[
first_metric_col
].iloc[0]
# Build the tab name with all available metrics for a quick overview.
tab_name_metrics_parts = []
mean_metric_columns = [
col
for col in template_summary_df.columns
if col.startswith("metrics.") and "/mean" in col
]
for metric_col in mean_metric_columns:
metric_name_short = metric_col.replace("metrics.", "").replace(
"/mean", ""
)
metric_val = template_summary_df[metric_col].iloc[0]
if metric_name_short == "calculated_exact_match" and isinstance(
metric_val, float
):
tab_name_metrics_parts.append(
f"{metric_name_short}: {metric_val:.1%}"
)
else:
tab_name_metrics_parts.append(
f"{metric_name_short}: {metric_val:.3f}"
if isinstance(metric_val, float)
else f"{metric_name_short}: {metric_val}"
)
tab_name = f"Template {i}"
if tab_name_metrics_parts:
tab_name += f" ({', '.join(tab_name_metrics_parts)})"
current_summary_df_display = template_summary_df.copy()
if "prompt" in current_summary_df_display.columns:
current_summary_df_display = current_summary_df_display.drop(
columns=["prompt"]
)
processed_results_for_tabs.append(
{
"name": tab_name,
"template_text": prompt_text,
"primary_score_label": primary_score_label,
"primary_score_value": primary_score_value,
"summary_metrics_df": current_summary_df_display,
"detailed_eval_df": detailed_eval_df,
}
)
if (
processed_results_for_tabs
): # If we successfully processed data, show the new UI
st.write("### Interactive Prompt Versions")
tab_titles = [res["name"] for res in processed_results_for_tabs]
tabs = st.tabs(tab_titles)
for i, tab_content in enumerate(tabs):
with tab_content:
result_data = processed_results_for_tabs[i]
st.subheader("Prompt Template")
# Sanitize tab name for key
clean_key_name = "".join(
filter(str.isalnum, result_data["name"])
)
st.text_area(
"Template",
value=result_data["template_text"],
height=200,
disabled=True,
key=f"template_view_{clean_key_name}_{i}",
)
st.subheader("Primary Score")
score_val = result_data["primary_score_value"]
score_label = result_data["primary_score_label"]
if score_label == "Calculated Exact Match" and isinstance(
score_val, float
):
st.metric(label=score_label, value=f"{score_val:.2%}")
else:
st.metric(
label=score_label,
value=f"{score_val:.4f}"
if isinstance(score_val, float)
else str(score_val),
)
if not result_data["summary_metrics_df"].empty:
st.subheader("Summary Metrics (from templates.json)")
st.dataframe(result_data["summary_metrics_df"])
if not result_data["detailed_eval_df"].empty:
st.subheader(
"Detailed Evaluation Results (from eval_results.json)"
)
st.dataframe(result_data["detailed_eval_df"])
else:
st.caption(
"No detailed evaluation results available for this template."
)
else:
st.warning("No valid results could be processed for display.")
except Exception as e:
st.error(f"An error occurred while trying to display results: {e}")
logger.error(f"Error in results display section: {e}", exc_info=True)
st.markdown(
"For now, you can access the results directly at the GCS path shown above."
)
def main() -> None:
"""Renders the Streamlit page for viewing Prompt Optimization Results."""
st.set_page_config(
layout="wide",
page_title="Prompt Optimization Results",
page_icon="assets/favicon.ico",
)
st.header("Prompt Optimization Results Browser")
if "storage_client" not in st.session_state:
try:
st.session_state["storage_client"] = storage.Client()
logger.info("Storage client initialized.")
except Exception as e:
st.error(f"Failed to initialize Google Cloud Storage client: {e}")
logger.error(
f"Failed to initialize Google Cloud Storage client: {e}", exc_info=True
)
st.session_state["storage_client"] = None
return
bucket_name = os.getenv("BUCKET")
if not bucket_name:
st.error("BUCKET environment variable is not set. Please configure it in .env.")
return
# --- Step 1: Select Operation ID ---
op_ids = list_gcs_directories(
bucket_name, BASE_OPTIMIZATION_PREFIX, st.session_state.storage_client
)
if not op_ids:
st.info(
f"No optimization operation IDs found under gs://{bucket_name}/{BASE_OPTIMIZATION_PREFIX}"
)
return
if "op_id" in st.session_state and st.session_state.op_id:
st.caption(
f"Hint: The last optimization run you initiated had the ID: `{st.session_state.op_id}`."
)
selected_op_id = st.selectbox(
"Select an Operation ID:", options=[None, *op_ids], key="selected_op_id_results"
)
if not selected_op_id:
st.write("Please select an Operation ID to see its optimization job runs.")
return
st.divider()
# --- Step 2: Select Experiment Run ---
st.subheader(f"Optimization Job Runs for Operation ID: {selected_op_id}")
optimization_jobs_prefix = (
f"{BASE_OPTIMIZATION_PREFIX}{selected_op_id}/{OPTIMIZATION_JOBS_SUBDIR}"
)
experiment_runs = list_gcs_directories(
bucket_name, optimization_jobs_prefix, st.session_state.storage_client
)
if not experiment_runs:
st.info(
f"No completed optimization job runs found under gs://{bucket_name}/{optimization_jobs_prefix}"
)
return
selected_run = st.selectbox(
"Select an Optimization Job Run:",
options=[None, *experiment_runs],
key="selected_experiment_run",
)
if not selected_run:
st.write("Please select an optimization job run to view its results.")
return
st.divider()
# --- Step 3: Check Job Status and Display Results ---
st.subheader(f"Results for: {selected_run}")
project_id = os.getenv("PROJECT_ID")
location = os.getenv("LOCATION")
if not project_id or not location:
st.error("PROJECT_ID or REGION environment variables are not set.")
return
try:
jobs = list_custom_training_jobs(project_id=project_id, location=location)
job_status = "Not Found"
for job in jobs:
if job["display_name"] == selected_run:
job_status = job["status"]
break
st.info(f"Status for job '{selected_run}': **{job_status}**")
if job_status == "JOB_STATE_FAILED":
st.error(
"This optimization job has failed. Please check the logs in the Agent Platform console for more details."
)
return
if job_status not in ["JOB_STATE_SUCCEEDED", "JOB_STATE_CANCELLED"]:
st.warning(
f"Job is currently in status: {job_status}. Results may be incomplete."
)
except Exception as e:
st.error(f"Could not retrieve job status. Error: {e}")
logger.error(
f"Failed to retrieve job status for {selected_run}: {e}", exc_info=True
)
run_uri = f"gs://{bucket_name}/{optimization_jobs_prefix}{selected_run}"
st.info(f"Loading results from: {run_uri}")
results_ui = vapo_lib.ResultsUI(run_uri)
_display_interactive_results(results_ui)
if __name__ == "__main__":
main()