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
282 lines
12 KiB
Python
282 lines
12 KiB
Python
import asyncio
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
import numpy as np
|
|
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()
|
|
|
|
SUPPORTED_METRICS = (
|
|
"grounding", "answer_relevance", "answer_quality", "context_precision",
|
|
"answer_similarity", "creativity", "context_recall", "answer_correctness",
|
|
)
|
|
|
|
# Load Jinja2 prompt templates
|
|
_TEMPLATES_DIR = Path(__file__).parent
|
|
_TEMPLATES = {}
|
|
for name in ["grounding", "answer_quality", "answer_similarity", "creativity",
|
|
"context_recall", "context_precision", "answer_relevance", "answer_correctness"]:
|
|
path = _TEMPLATES_DIR / f"{name}.jinja2"
|
|
if path.exists():
|
|
_TEMPLATES[name] = Template(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
@dataclass
|
|
class EvalInput:
|
|
question: str
|
|
answer: str
|
|
context: str
|
|
ground_truth: str
|
|
metrics: str = (
|
|
"grounding,answer_relevance,answer_quality,"
|
|
"context_precision,answer_similarity,creativity,"
|
|
"context_recall,answer_correctness"
|
|
)
|
|
|
|
|
|
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(question: str, answer: str, context: str,
|
|
ground_truth: str,
|
|
selected_metrics: dict) -> dict:
|
|
dict_metric_required_fields = {
|
|
"answer_relevance": {"question", "answer"},
|
|
"answer_quality": {"question", "answer"},
|
|
"creativity": {"question", "answer"},
|
|
"grounding": {"answer", "context"},
|
|
"context_recall": {"question", "context", "ground_truth"},
|
|
"context_precision": {"question", "context", "ground_truth"},
|
|
"answer_similarity": {"question", "answer", "ground_truth"},
|
|
"answer_correctness": {"question", "answer", "ground_truth"},
|
|
}
|
|
input_data = {"question": question, "answer": answer, "context": context, "ground_truth": ground_truth}
|
|
actual_input_cols = {col for col, val in input_data.items() if val and val.strip()}
|
|
|
|
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
|
|
if data_validation.get("answer_correctness"):
|
|
data_validation["answer_similarity"] = True
|
|
return data_validation
|
|
|
|
|
|
def _get_score(result: Optional[str]) -> Optional[float]:
|
|
if result is None:
|
|
return None
|
|
try:
|
|
result_dict = json.loads(result)
|
|
return result_dict.get("score", None)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
|
|
def _cosine_similarity(a: List[float], b: List[float]) -> float:
|
|
a_arr = np.array(a)
|
|
b_arr = np.array(b)
|
|
dot = np.dot(a_arr, b_arr)
|
|
return dot / (np.linalg.norm(a_arr) * np.linalg.norm(b_arr))
|
|
|
|
|
|
def _calculate_context_recall(llm_result: str) -> float:
|
|
try:
|
|
response = json.loads(llm_result)
|
|
if response:
|
|
result = response.get("result", "")
|
|
if result:
|
|
response_vals = [
|
|
int(item.get("attributed", "").lower() == "yes" or
|
|
item.get("attribited", "").lower() == "yes")
|
|
if item.get("attributed") or item.get("attribited")
|
|
else np.nan
|
|
for item in result
|
|
]
|
|
denom = len(response_vals)
|
|
numerator = sum(response_vals)
|
|
score = 5 * numerator / denom
|
|
return max(score, 1)
|
|
return 1
|
|
except Exception:
|
|
return np.nan
|
|
|
|
|
|
def _calculate_answer_correctness(statement_result: str, similarity_score: float) -> float:
|
|
try:
|
|
weights = [0.75, 0.25]
|
|
key_map = {
|
|
"TP": "statements that are present in both the answer and the ground truth",
|
|
"FP": "statements present in the answer but not found in the ground truth",
|
|
"FN": "relevant statements found in the ground truth but omitted in the answer",
|
|
}
|
|
score = 0
|
|
result = json.loads(statement_result)
|
|
if result:
|
|
prediction = [result.get(key_map[k], np.nan) for k in key_map.keys()]
|
|
tp, fp, fn = [len(item) if isinstance(item, list) else np.nan for item in prediction]
|
|
score = 5 * tp / (tp + 0.5 * (fp + fn))
|
|
final_score = weights[0] * score + weights[1] * int(similarity_score)
|
|
return max(final_score, 1)
|
|
except Exception:
|
|
return np.nan
|
|
|
|
|
|
class SingleTurnMetricsExecutor(Executor):
|
|
"""Evaluates single-turn QA on up to 8 metrics.
|
|
|
|
Handles conditional activation (gap #4), embedding calls (gap #8),
|
|
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")
|
|
self._embedding_deployment = os.environ.get("AZURE_OPENAI_EMBEDDING_DEPLOYMENT", "text-embedding-ada-002")
|
|
|
|
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 _embed(self, text: str) -> List[float]:
|
|
response = await self._client.embeddings.create(
|
|
model=self._embedding_deployment, input=text,
|
|
)
|
|
return response.data[0].embedding
|
|
|
|
async def _eval_simple_llm(self, metric_name: str, **template_vars) -> Optional[str]:
|
|
template = _TEMPLATES.get(metric_name)
|
|
if not template:
|
|
return None
|
|
prompt = template.render(**template_vars)
|
|
return await self._llm_call(prompt)
|
|
|
|
async def _eval_grounding(self, answer: str, context: str) -> Optional[float]:
|
|
raw = await self._eval_simple_llm("grounding", answer=answer, context=context)
|
|
return _get_score(raw) if raw else None
|
|
|
|
async def _eval_answer_quality(self, question: str, answer: str) -> Optional[float]:
|
|
raw = await self._eval_simple_llm("answer_quality", question=question, answer=answer)
|
|
return _get_score(raw) if raw else None
|
|
|
|
async def _eval_creativity(self, question: str, answer: str) -> Optional[float]:
|
|
raw = await self._eval_simple_llm("creativity", question=question, answer=answer)
|
|
return _get_score(raw) if raw else None
|
|
|
|
async def _eval_context_precision(
|
|
self, question: str, context: str,
|
|
ground_truth: str) -> Optional[float]:
|
|
raw = await self._eval_simple_llm(
|
|
"context_precision", question=question,
|
|
context=context, ground_truth=ground_truth)
|
|
return _get_score(raw) if raw else None
|
|
|
|
async def _eval_answer_similarity(
|
|
self, question: str, answer: str,
|
|
ground_truth: str) -> Optional[float]:
|
|
raw = await self._eval_simple_llm(
|
|
"answer_similarity", question=question,
|
|
answer=answer, ground_truth=ground_truth)
|
|
return _get_score(raw) if raw else None
|
|
|
|
async def _eval_context_recall(
|
|
self, question: str, context: str,
|
|
ground_truth: str) -> Optional[float]:
|
|
raw = await self._eval_simple_llm(
|
|
"context_recall", question=question,
|
|
context=context, ground_truth=ground_truth)
|
|
if raw is None:
|
|
return None
|
|
return _calculate_context_recall(raw)
|
|
|
|
async def _eval_answer_relevance(self, question: str, answer: str, context: str) -> Optional[float]:
|
|
raw = await self._eval_simple_llm("answer_relevance", answer=answer, context=context)
|
|
if raw is None:
|
|
return None
|
|
try:
|
|
parsed = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
parsed = {"question": "", "noncommittal": True}
|
|
generated_q = parsed.get("question", "")
|
|
noncommittal = parsed.get("noncommittal", True)
|
|
q_embed, gq_embed = await asyncio.gather(
|
|
self._embed(question), self._embed(generated_q)
|
|
)
|
|
cosine_sim = _cosine_similarity(q_embed, gq_embed)
|
|
score = 5 * cosine_sim * int(not noncommittal)
|
|
return max(score, 1)
|
|
|
|
async def _eval_answer_correctness(
|
|
self, question: str, answer: str,
|
|
ground_truth: str,
|
|
similarity_score: float) -> Optional[float]:
|
|
raw = await self._eval_simple_llm(
|
|
"answer_correctness", question=question,
|
|
answer=answer, ground_truth=ground_truth)
|
|
if raw is None:
|
|
return None
|
|
return _calculate_answer_correctness(raw, similarity_score)
|
|
|
|
@handler
|
|
async def evaluate(self, input: EvalInput, ctx: WorkflowContext[Never, dict]) -> None:
|
|
selected = _select_metrics(input.metrics)
|
|
validated = _validate_input(input.question, input.answer, input.context, input.ground_truth, selected)
|
|
|
|
results = {m: None for m in SUPPORTED_METRICS}
|
|
|
|
# Phase 1: Run independent metrics concurrently
|
|
tasks = {}
|
|
if validated.get("grounding"):
|
|
tasks["grounding"] = self._eval_grounding(input.answer, input.context)
|
|
if validated.get("answer_quality"):
|
|
tasks["answer_quality"] = self._eval_answer_quality(input.question, input.answer)
|
|
if validated.get("creativity"):
|
|
tasks["creativity"] = self._eval_creativity(input.question, input.answer)
|
|
if validated.get("context_precision"):
|
|
tasks["context_precision"] = self._eval_context_precision(input.question, input.context, input.ground_truth)
|
|
if validated.get("context_recall"):
|
|
tasks["context_recall"] = self._eval_context_recall(input.question, input.context, input.ground_truth)
|
|
if validated.get("answer_relevance"):
|
|
tasks["answer_relevance"] = self._eval_answer_relevance(input.question, input.answer, input.context)
|
|
if validated.get("answer_similarity"):
|
|
tasks["answer_similarity"] = self._eval_answer_similarity(input.question, input.answer, input.ground_truth)
|
|
|
|
if tasks:
|
|
keys = list(tasks.keys())
|
|
values = await asyncio.gather(*tasks.values())
|
|
for k, v in zip(keys, values):
|
|
results[k] = v
|
|
|
|
# Phase 2: answer_correctness depends on answer_similarity
|
|
if validated.get("answer_correctness"):
|
|
sim_score = results.get("answer_similarity") or 0
|
|
results["answer_correctness"] = await self._eval_answer_correctness(
|
|
input.question, input.answer, input.ground_truth, sim_score
|
|
)
|
|
|
|
await ctx.yield_output(results)
|
|
|
|
|
|
def create_workflow():
|
|
_executor = SingleTurnMetricsExecutor(id="single_turn_metrics")
|
|
return WorkflowBuilder(name="EvalSingleTurnMetricsRow", start_executor=_executor).build()
|