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

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())