193 lines
6.3 KiB
Python
193 lines
6.3 KiB
Python
"""Custom LLM Evaluator"""
|
|
|
|
import asyncio
|
|
from collections.abc import Callable
|
|
import logging
|
|
import re
|
|
|
|
from backend.rag.claude_vertex import ClaudeVertexLLM
|
|
from google.cloud import bigquery
|
|
from llama_index.core.base.response.schema import Response
|
|
from llama_index.core.chat_engine.types import AgentChatResponse
|
|
import pandas as pd
|
|
from vertexai.generative_models import (
|
|
GenerationConfig,
|
|
GenerativeModel,
|
|
HarmBlockThreshold,
|
|
HarmCategory,
|
|
)
|
|
|
|
logging.basicConfig(level=logging.INFO) # Set the desired logging level
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class LLMEvaluator:
|
|
"""
|
|
LLMEvaluator.evaluate
|
|
LLMEvaluator.async_eval_retrieval
|
|
LLMEvaluator.extract_score
|
|
LLMEvaluator.async_eval_question_answer_pair
|
|
LLMEvaluator.async_eval_answer
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
system_prompt: str,
|
|
user_prompt: str,
|
|
eval_model_name: str,
|
|
temperature: float,
|
|
):
|
|
self.system_prompt = system_prompt
|
|
self.user_prompt = user_prompt
|
|
self.eval_model_name = eval_model_name
|
|
|
|
if "gemini" in self.eval_model_name:
|
|
self.eval_model = GenerativeModel(
|
|
model_name=self.eval_model_name,
|
|
system_instruction=self.system_prompt,
|
|
generation_config=GenerationConfig(
|
|
temperature=temperature, max_output_tokens=3000
|
|
),
|
|
safety_settings={
|
|
HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: HarmBlockThreshold.BLOCK_NONE,
|
|
HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_NONE,
|
|
HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_NONE,
|
|
HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_NONE,
|
|
},
|
|
)
|
|
|
|
elif "claude" in self.eval_model_name:
|
|
self.eval_model = ClaudeVertexLLM(
|
|
project_id="sysco-smarter-catalog",
|
|
region="us-east5",
|
|
model_name="claude-3-5-sonnet@20240620",
|
|
max_tokens=1024,
|
|
system_prompt=self.system_prompt,
|
|
)
|
|
|
|
async def async_eval_answer(
|
|
self,
|
|
generative_model: GenerativeModel,
|
|
question: str,
|
|
answer: str,
|
|
ground_truth: str,
|
|
retrieved_context: str,
|
|
) -> str:
|
|
"""
|
|
LLMEvaluator.async_eval_answer
|
|
"""
|
|
logger.info(f"Evaluating question: {question}")
|
|
# Stop sequence to cut the model off after outputting an integer
|
|
result = await generative_model.generate_content_async(
|
|
self.user_prompt.format(
|
|
question=question,
|
|
answer=answer,
|
|
ground_truth=ground_truth,
|
|
context=retrieved_context,
|
|
)
|
|
)
|
|
|
|
logger.info(f"Finished Evaluating question: {question}")
|
|
return result.text
|
|
|
|
async def async_eval_question_answer_pair(
|
|
self,
|
|
retrieval_qa_func,
|
|
eval_model: GenerativeModel,
|
|
question: str,
|
|
ground_truth: str,
|
|
) -> tuple:
|
|
"""
|
|
LLMEvaluator.async_eval_question_answer_pair
|
|
"""
|
|
response = await retrieval_qa_func(question)
|
|
if (type(response) == Response) or (type(response) == AgentChatResponse):
|
|
answer = response.response
|
|
retrieved_context = [r.node.text for r in response.source_nodes]
|
|
else:
|
|
retrieved_context = None
|
|
answer = response
|
|
|
|
score = await self.async_eval_answer(
|
|
eval_model, question, answer, ground_truth, retrieved_context
|
|
)
|
|
return answer, score, retrieved_context
|
|
|
|
@staticmethod
|
|
def extract_score(text: str) -> str | None:
|
|
"""
|
|
LLMEvaluator.extract_score
|
|
"""
|
|
"""Extracts a number (0-100) from the first line of a string.
|
|
|
|
Args:
|
|
text (str): The text to search.
|
|
|
|
Returns:
|
|
str or None: The extracted number as a string, or None if no number is found.
|
|
"""
|
|
first_line = text.splitlines()[0] # Get the first line
|
|
match = re.search(r"\d{1,3}", first_line) # Search for a number (1-3 digits)
|
|
if match:
|
|
return int(match.group()) # Return the matched number as a string
|
|
else:
|
|
return 0 # Return None if no number is found
|
|
|
|
async def async_eval_retrieval(
|
|
self, retrieval_qa_func: Callable, eval_df: pd.DataFrame
|
|
) -> pd.DataFrame:
|
|
"""
|
|
LLMEvaluator.async_eval_retrieval
|
|
"""
|
|
results = await asyncio.gather(
|
|
*[
|
|
self.async_eval_question_answer_pair(
|
|
retrieval_qa_func, self.eval_model, x["question"], x["ground_truth"]
|
|
)
|
|
for idx, x in eval_df[["question", "ground_truth"]].iterrows()
|
|
]
|
|
)
|
|
answers, eval_result, retrieved_context = list(zip(*results))
|
|
eval_df["answer"] = answers
|
|
eval_df["retrieved_context"] = retrieved_context
|
|
eval_df["eval_result"] = eval_result
|
|
eval_df["score"] = eval_df["eval_result"].apply(lambda x: self.extract_score(x))
|
|
return eval_df
|
|
|
|
def evaluate(
|
|
self, retrieval_qa_func: Callable, eval_df: pd.DataFrame
|
|
) -> pd.DataFrame:
|
|
"""
|
|
LLMEvaluator.evaluate
|
|
"""
|
|
eval_df = asyncio.run(self.async_eval_retrieval(retrieval_qa_func, eval_df))
|
|
return eval_df
|
|
|
|
|
|
def write_results_to_bq(
|
|
pd_dataframe: pd.DataFrame, table_id: str = "eval_results.eval_results_table"
|
|
):
|
|
"""
|
|
write_results_to_bq
|
|
"""
|
|
logger.info("Writing results to BQ...")
|
|
client = bigquery.Client()
|
|
|
|
# Define the job configuration
|
|
job_config = bigquery.LoadJobConfig(
|
|
# Automatically detect schema from DataFrame
|
|
autodetect=True,
|
|
# Write disposition (WRITE_TRUNCATE, WRITE_APPEND, WRITE_EMPTY)
|
|
write_disposition="WRITE_APPEND", # Choose the appropriate disposition
|
|
# Specify the source format (PARQUET, CSV, NEWLINE_DELIMITED_JSON, etc.)
|
|
source_format=bigquery.SourceFormat.PARQUET,
|
|
)
|
|
|
|
# Load the DataFrame into BigQuery
|
|
job = client.load_table_from_dataframe(
|
|
pd_dataframe, table_id, job_config=job_config
|
|
)
|
|
|
|
# Wait for the job to complete
|
|
logger.info(job.result())
|