项目文件夹

文件
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 行
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()