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

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