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

189 lines
7.3 KiB
Python

import asyncio
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import 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 = ("gpt_relevance", "gpt_groundedness", "gpt_retrieval_score")
# Load Jinja2 prompt templates
_TEMPLATES_DIR = Path(__file__).parent
_TEMPLATES = {}
for name in ["rag_groundedness_prompt", "rag_retrieval_prompt", "rag_generation_prompt"]:
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
documents: str
metrics: str = "gpt_groundedness,gpt_relevance,gpt_retrieval_score"
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, documents: str, selected_metrics: dict) -> dict:
dict_metric_required_fields = {
"gpt_groundedness": {"question", "answer", "documents"},
"gpt_relevance": {"question", "answer", "documents"},
"gpt_retrieval_score": {"question", "documents"},
}
input_data = {"question": question, "answer": answer, "documents": documents}
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
return data_validation
def _parse_groundedness_score(raw: str) -> dict:
try:
numbers_found = re.findall(r"Quality score:\s*(\d+)\/\d", raw)
score = float(numbers_found[0]) if numbers_found else 0
except Exception:
score = float("nan")
try:
quality_reasoning, _ = raw.split("Quality score: ")
except Exception:
quality_reasoning = raw
return {"quality_score": score, "quality_reasoning": quality_reasoning}
def _parse_generation_score(raw: str) -> dict:
quality_score = float("nan")
quality_reasoning = ""
for sent in raw.split("\n"):
sent = sent.strip()
if re.match(r"\s*(<)?Quality score:", sent):
numbers_found = re.findall(r"(\d+\.*\d*)\/", sent)
if numbers_found:
quality_score = int(float(numbers_found[0].replace("'", "")))
for sent in raw.split("\n"):
sent = sent.strip()
if re.match(r"\s*(<)?Quality score reasoning:", sent):
quality_reasoning = sent.strip()
break
return {"quality_score": quality_score, "quality_reasoning": quality_reasoning}
def _parse_retrieval_score(raw: str) -> dict:
score_response = [
sent.strip() for sent in raw.strip('"').split("# Result")[-1].strip().split(".")
if sent.strip()
]
parsed = re.findall(r"\d+", score_response[-1]) if score_response else []
if parsed:
score = float(parsed[-1].strip())
if score < 1.0 or score > 5.0:
score = float("nan")
else:
score = float("nan")
try:
reasoning_response, _ = raw.split("# Result")
except Exception:
reasoning_response = raw
return {"quality_score": score, "quality_reasoning": reasoning_response}
class QnaRagMetricsExecutor(Executor):
"""Evaluates QnA RAG on 3 metrics: groundedness, relevance, retrieval."""
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, max_tokens=1000,
presence_penalty=0, frequency_penalty=0,
)
return response.choices[0].message.content or ""
async def _eval_groundedness(self, question: str, answer: str, documents: str) -> Optional[dict]:
template = _TEMPLATES.get("rag_groundedness_prompt")
if not template:
return None
prompt = template.render(question=question, answer=answer, FullBody=documents)
raw = await self._llm_call(prompt)
return _parse_groundedness_score(raw)
async def _eval_relevance(self, question: str, answer: str, documents: str) -> Optional[dict]:
template = _TEMPLATES.get("rag_generation_prompt")
if not template:
return None
prompt = template.render(question=question, answer=answer, FullBody=documents)
raw = await self._llm_call(prompt)
return _parse_generation_score(raw)
async def _eval_retrieval(self, question: str, documents: str) -> Optional[dict]:
template = _TEMPLATES.get("rag_retrieval_prompt")
if not template:
return None
prompt = template.render(question=question, FullBody=documents)
raw = await self._llm_call(prompt)
return _parse_retrieval_score(raw)
@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.documents, selected)
tasks = {}
if validated.get("gpt_groundedness"):
tasks["gpt_groundedness"] = self._eval_groundedness(input.question, input.answer, input.documents)
if validated.get("gpt_relevance"):
tasks["gpt_relevance"] = self._eval_relevance(input.question, input.answer, input.documents)
if validated.get("gpt_retrieval_score"):
tasks["gpt_retrieval_score"] = self._eval_retrieval(input.question, input.documents)
parsed_results = {}
if tasks:
keys = list(tasks.keys())
values = await asyncio.gather(*tasks.values())
for k, v in zip(keys, values):
parsed_results[k] = v
# Extract quality_score from each parsed result
variant_result = {}
for metric in SUPPORTED_METRICS:
parsed = parsed_results.get(metric)
if parsed:
try:
variant_result[metric] = float(parsed["quality_score"])
except (ValueError, TypeError, KeyError):
variant_result[metric] = np.nan
else:
variant_result[metric] = np.nan
await ctx.yield_output(variant_result)
def create_workflow():
_executor = QnaRagMetricsExecutor(id="qna_rag_metrics")
return WorkflowBuilder(name="EvalQnaRagMetricsRow", start_executor=_executor).build()