Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:39:52 +08:00

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