e768098d0e
tools_continuous_delivery / Private PyPI non-main branch release (push) Has been skipped
tools_continuous_delivery / Private PyPI main branch release (push) Failing after 2m42s
Publish Promptflow Doc / Build (push) Has been cancelled
Publish Promptflow Doc / Deploy (push) Has been cancelled
Flake8 Lint / flake8 (push) Has been cancelled
Spell check CI / Spell_Check (push) Has been cancelled
174 lines
6.6 KiB
Python
174 lines
6.6 KiB
Python
import asyncio
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from statistics import mean
|
|
from typing import Optional
|
|
|
|
from dotenv import load_dotenv
|
|
from jinja2 import Template
|
|
from openai import AsyncAzureOpenAI
|
|
from typing_extensions import Never
|
|
from agent_framework import Executor, WorkflowBuilder, WorkflowContext, handler
|
|
|
|
load_dotenv()
|
|
|
|
# Load Jinja2 prompt templates
|
|
_TEMPLATES_DIR = Path(__file__).parent
|
|
_ANSWER_RELEVANCE_TEMPLATE = Template(
|
|
_TEMPLATES_DIR.joinpath("answer_relevance.jinja2").read_text(encoding="utf-8")
|
|
)
|
|
_CONVERSATION_QUALITY_TEMPLATE = Template(
|
|
_TEMPLATES_DIR.joinpath("conversation_quality_prompt.jinja2").read_text(encoding="utf-8")
|
|
)
|
|
_CREATIVITY_TEMPLATE = Template(
|
|
_TEMPLATES_DIR.joinpath("creativity.jinja2").read_text(encoding="utf-8")
|
|
)
|
|
_GROUNDING_PROMPT = _TEMPLATES_DIR.joinpath("grounding_prompt.jinja2").read_text(encoding="utf-8")
|
|
|
|
SUPPORTED_METRICS = ("answer_relevance", "conversation_quality", "creativity", "grounding")
|
|
|
|
|
|
@dataclass
|
|
class EvalInput:
|
|
chat_history: list
|
|
metrics: str = "creativity,conversation_quality,answer_relevance,grounding"
|
|
|
|
|
|
def _select_metrics(metrics_str: str) -> dict:
|
|
user_selected = [m.strip() for m in metrics_str.split(",") if m.strip()]
|
|
return {m: (m in user_selected) for m in SUPPORTED_METRICS}
|
|
|
|
|
|
def _validate_input(chat_history: list, selected_metrics: dict) -> dict:
|
|
dict_metric_required_fields = {
|
|
"answer_relevance": {"question", "answer"},
|
|
"conversation_quality": {"question", "answer"},
|
|
"creativity": {"question", "answer"},
|
|
"grounding": {"answer", "context"},
|
|
}
|
|
actual_input_cols = set()
|
|
for item in chat_history[:1]:
|
|
actual_input_cols.update(item.get("inputs", {}).keys())
|
|
actual_input_cols.update(item.get("outputs", {}).keys())
|
|
|
|
data_validation = dict(selected_metrics)
|
|
for metric in selected_metrics:
|
|
if selected_metrics[metric]:
|
|
if not dict_metric_required_fields[metric] <= actual_input_cols:
|
|
data_validation[metric] = False
|
|
return data_validation
|
|
|
|
|
|
def _convert_chat_history_to_conversation(chat_history: list) -> str:
|
|
conversation = ""
|
|
for i in chat_history:
|
|
conversation += f"User: {i['inputs']['question']}\nBot: {i['outputs']['answer']}\n"
|
|
return conversation
|
|
|
|
|
|
def _get_score(result: Optional[str]) -> Optional[float]:
|
|
if result is None:
|
|
return None
|
|
try:
|
|
result_dict = json.loads(result)
|
|
score = result_dict.get("score", None)
|
|
return score
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
|
|
class MultiTurnMetricsExecutor(Executor):
|
|
"""Evaluates multi-turn conversations on up to 4 metrics.
|
|
|
|
Handles conditional metric activation (gap #4), multi-turn grounding iteration
|
|
(gap #10), and dot-notation output access (gap #11) all within a single executor.
|
|
"""
|
|
|
|
def __init__(self, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self._client = AsyncAzureOpenAI(
|
|
azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
|
|
api_version=os.environ.get("AZURE_OPENAI_API_VERSION", "2024-02-01"),
|
|
api_key=os.environ["AZURE_OPENAI_API_KEY"],
|
|
)
|
|
self._deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT", "gpt-4")
|
|
|
|
async def _llm_call(self, prompt: str) -> str:
|
|
response = await self._client.chat.completions.create(
|
|
model=self._deployment,
|
|
messages=[{"role": "system", "content": prompt}],
|
|
temperature=0,
|
|
top_p=1,
|
|
presence_penalty=0,
|
|
frequency_penalty=0,
|
|
)
|
|
return response.choices[0].message.content or ""
|
|
|
|
async def _eval_answer_relevance(self, conversation: str) -> Optional[float]:
|
|
prompt = _ANSWER_RELEVANCE_TEMPLATE.render(conversation=conversation)
|
|
result = await self._llm_call(prompt)
|
|
return _get_score(result)
|
|
|
|
async def _eval_conversation_quality(self, conversation: str) -> Optional[float]:
|
|
prompt = _CONVERSATION_QUALITY_TEMPLATE.render(conversation=conversation)
|
|
result = await self._llm_call(prompt)
|
|
return _get_score(result)
|
|
|
|
async def _eval_creativity(self, conversation: str) -> Optional[float]:
|
|
prompt = _CREATIVITY_TEMPLATE.render(conversation=conversation)
|
|
result = await self._llm_call(prompt)
|
|
return _get_score(result)
|
|
|
|
async def _eval_grounding(self, chat_history: list) -> Optional[float]:
|
|
"""Iterates through chat_history turns, calls LLM per turn, averages scores (gap #10)."""
|
|
scores = []
|
|
for item in chat_history:
|
|
context = item["outputs"].get("context", "")
|
|
answer = item["outputs"].get("answer", "")
|
|
prompt = _GROUNDING_PROMPT.replace("{context}", context).replace("{answer}", answer)
|
|
result = await self._llm_call(prompt)
|
|
try:
|
|
scores.append(int(result.strip()))
|
|
except (ValueError, TypeError):
|
|
pass
|
|
if scores:
|
|
return mean(scores)
|
|
return None
|
|
|
|
@handler
|
|
async def evaluate(self, input: EvalInput, ctx: WorkflowContext[Never, dict]) -> None:
|
|
selected = _select_metrics(input.metrics)
|
|
validated = _validate_input(input.chat_history, selected)
|
|
conversation = _convert_chat_history_to_conversation(input.chat_history)
|
|
|
|
tasks = {}
|
|
if validated.get("answer_relevance"):
|
|
tasks["answer_relevance"] = self._eval_answer_relevance(conversation)
|
|
if validated.get("conversation_quality"):
|
|
tasks["conversation_quality"] = self._eval_conversation_quality(conversation)
|
|
if validated.get("creativity"):
|
|
tasks["creativity"] = self._eval_creativity(conversation)
|
|
if validated.get("grounding"):
|
|
tasks["grounding"] = self._eval_grounding(input.chat_history)
|
|
|
|
results_dict = {}
|
|
if tasks:
|
|
keys = list(tasks.keys())
|
|
values = await asyncio.gather(*tasks.values())
|
|
for k, v in zip(keys, values):
|
|
results_dict[k] = v
|
|
|
|
# Fill in None for metrics that were not computed
|
|
for metric in SUPPORTED_METRICS:
|
|
if metric not in results_dict:
|
|
results_dict[metric] = None
|
|
|
|
await ctx.yield_output(results_dict)
|
|
|
|
|
|
def create_workflow():
|
|
_executor = MultiTurnMetricsExecutor(id="multi_turn_metrics")
|
|
return WorkflowBuilder(name="EvalMultiTurnMetricsRow", start_executor=_executor).build()
|