296 lines
13 KiB
Python
296 lines
13 KiB
Python
import os
|
||
import time
|
||
import base64
|
||
import requests
|
||
from typing import List, Dict, Any, Optional
|
||
|
||
from dotenv import load_dotenv
|
||
from groundx import GroundX, Document
|
||
from openai import OpenAI
|
||
|
||
import opik
|
||
from opik.evaluation import evaluate
|
||
from opik.evaluation.metrics import BaseMetric, GEval
|
||
|
||
load_dotenv()
|
||
|
||
def evaluate_invoice_parsing(model_output: str, expected_output: str, question: str):
|
||
"""
|
||
Evaluate invoice parsing results using Comet Opik's GEval metrics.
|
||
|
||
Args:
|
||
model_output (str): The model's extracted answer
|
||
expected_output (str): The expected correct answer
|
||
question (str): The question that was asked
|
||
|
||
Returns:
|
||
dict: A dictionary containing evaluation results with the following structure:
|
||
{
|
||
"overall_score": float, # Score on 0-10 scale
|
||
"reason": str, # Detailed reason for the score
|
||
"passed": bool, # Whether score >= 7.0 (70% threshold)
|
||
"error": str, optional # Error message if evaluation fails
|
||
}
|
||
"""
|
||
try:
|
||
# Validate input
|
||
if not model_output or not expected_output:
|
||
raise ValueError("Model output and expected output cannot be empty")
|
||
|
||
# Build the context string that includes both expected and model answers
|
||
context = f"QUESTION: {question}\nEXPECTED_ANSWER: {expected_output}\nMODEL_ANSWER: {model_output}"
|
||
|
||
# Define rubric scoring criteria for invoice parsing
|
||
accuracy_rubric_text = (
|
||
"Score 0-2: Completely incorrect or missing information\n"
|
||
"Score 3-5: Partially correct but missing key details\n"
|
||
"Score 6-8: Mostly correct with minor inaccuracies\n"
|
||
"Score 9-10: Completely accurate and matches expected answer"
|
||
)
|
||
|
||
# Invoice Parsing Accuracy Metric
|
||
accuracy_metric = GEval(
|
||
task_introduction=(
|
||
"You are an expert judge evaluating invoice parsing accuracy. "
|
||
"Compare the model's extracted answer against the expected answer. "
|
||
"Focus on whether the model correctly extracted the requested information. "
|
||
"Use the following rubric to assign scores:"
|
||
),
|
||
evaluation_criteria=(
|
||
"EVALUATION STEPS:\n"
|
||
"1. Read the QUESTION carefully to understand what information was requested.\n"
|
||
"2. Compare the EXPECTED_ANSWER with the MODEL_ANSWER.\n"
|
||
"3. Check if the model extracted the correct information.\n"
|
||
"4. Consider formatting differences (e.g., '$8.45' vs '8.45' vs 'The bill total is $8.45').\n"
|
||
"5. For 'data not available' cases, check if the model correctly indicated missing information.\n\n"
|
||
"SCORING RUBRIC:\n"
|
||
f"{accuracy_rubric_text}\n\n"
|
||
"SPECIAL CASES:\n"
|
||
"- If expected answer is 'data not available', the model should indicate information is not present\n"
|
||
"- If expected answer has a specific value, the model should match it exactly or semantically\n"
|
||
"- Ignore extra words like 'The bill total is' or 'The account number is' - focus on the actual value\n\n"
|
||
"Return only a score between 0 and 10, and a concise reason that references the rubric."
|
||
),
|
||
name="Invoice Parsing Accuracy",
|
||
)
|
||
|
||
# Run evaluation using direct GEval scoring
|
||
accuracy_result = accuracy_metric.score(output=context)
|
||
|
||
# Convert score from Opik's 0-1 scale to 0-10 scale
|
||
accuracy_score = accuracy_result.value * 10
|
||
|
||
# Return results
|
||
return {
|
||
"overall_score": accuracy_score,
|
||
"reason": accuracy_result.reason,
|
||
"passed": accuracy_score >= 7.0, # 70% threshold
|
||
}
|
||
|
||
except Exception as e:
|
||
# Error handling
|
||
return {
|
||
"error": f"Error evaluating invoice parsing: {str(e)}",
|
||
"overall_score": 0.0,
|
||
"reason": "Evaluation failed",
|
||
"passed": False,
|
||
}
|
||
|
||
|
||
class EvaluatorGEval:
|
||
"""Evaluator that scores answers using GEval (LLM judge with rubric)."""
|
||
|
||
def __init__(self, groundx_api_key: str, openai_api_key: str, comet_api_key: Optional[str] = None):
|
||
self.gx = GroundX(api_key=groundx_api_key)
|
||
self.oa = OpenAI(api_key=openai_api_key)
|
||
self.opik = opik.Opik(api_key=comet_api_key) if comet_api_key else opik.Opik()
|
||
self.bucket_id = self._ensure_bucket()
|
||
|
||
# ---------------- bucket helpers -----------------
|
||
def _ensure_bucket(self, name: str = "gx_eval") -> str:
|
||
for b in self.gx.buckets.list().buckets:
|
||
if b.name == name:
|
||
return b.bucket_id
|
||
return self.gx.buckets.create(name=name).bucket.bucket_id
|
||
|
||
# ---------------- ingestion helpers --------------
|
||
def process_invoice(self, file_path: str, file_name: str, mime_type: str) -> Dict[str, Any]:
|
||
ingest = self.gx.ingest(documents=[
|
||
Document(
|
||
bucket_id=int(self.bucket_id),
|
||
file_name=file_name,
|
||
file_path=file_path,
|
||
file_type=mime_type.split("/")[-1],
|
||
)
|
||
])
|
||
self._poll_until_complete(ingest.ingest.process_id)
|
||
return self._fetch_xray(file_name)
|
||
|
||
def _poll_until_complete(self, pid: str, timeout: int = 600):
|
||
start = time.time()
|
||
while True:
|
||
status = self.gx.documents.get_processing_status_by_id(process_id=pid).ingest.status
|
||
if status in {"complete", "error", "cancelled"}:
|
||
break
|
||
if time.time() - start > timeout:
|
||
raise TimeoutError("Ground X processing timed out.")
|
||
time.sleep(3)
|
||
if status != "complete":
|
||
raise RuntimeError(f"Ingest finished with status {status}")
|
||
|
||
def _fetch_xray(self, expected_name: str):
|
||
docs = self.gx.documents.lookup(id=self.bucket_id).documents
|
||
doc = next((d for d in docs if getattr(d, "file_name", None) == expected_name), docs[0])
|
||
if getattr(doc, "xray_url", None):
|
||
r = requests.get(doc.xray_url)
|
||
r.raise_for_status()
|
||
return r.json()
|
||
raise RuntimeError("X-Ray URL missing")
|
||
|
||
# ---------------- context & prompting ------------
|
||
@staticmethod
|
||
def _context(xray: Dict[str, Any]) -> str:
|
||
parts = []
|
||
if s := xray.get("fileSummary"):
|
||
parts.append(f"Summary: {s}")
|
||
for page in xray.get("documentPages", [])[:2]:
|
||
texts = [ch.get("text", "")[:500] for ch in page.get("chunks", [])[:3] if ch.get("text")]
|
||
if texts:
|
||
parts.append("Document Content: " + " ".join(texts))
|
||
return "\n\n".join(parts)
|
||
|
||
def _gpt_ctx(self, q: str, ctx: str) -> str:
|
||
prompt = (
|
||
"You are an AI assistant analysing an invoice.\n\n" + ctx +
|
||
f"\n\nUser Question: {q}\n\nAnswer concisely. If unknown reply 'data not available'."
|
||
)
|
||
resp = self.oa.chat.completions.create(
|
||
model="gpt-4o",
|
||
messages=[{"role": "user", "content": prompt}],
|
||
max_tokens=300,
|
||
temperature=0.3,
|
||
)
|
||
return resp.choices[0].message.content
|
||
|
||
def _gpt_direct(self, q: str, raw: bytes, mime: str) -> str:
|
||
img_uri = None
|
||
if mime.startswith("image/"):
|
||
img_uri = f"data:{mime};base64,{base64.b64encode(raw).decode()}"
|
||
print(f"DEBUG: Using image file directly")
|
||
elif mime == "application/pdf":
|
||
try:
|
||
import fitz
|
||
page = fitz.open(stream=raw, filetype="pdf").load_page(0)
|
||
png = page.get_pixmap(dpi=180).tobytes("png")
|
||
img_uri = f"data:image/png;base64,{base64.b64encode(png).decode()}"
|
||
print(f"DEBUG: Successfully converted PDF to image")
|
||
except Exception as e:
|
||
print(f"DEBUG: PDF conversion failed: {e}")
|
||
# Fallback: try to extract text from PDF
|
||
try:
|
||
import fitz
|
||
doc = fitz.open(stream=raw, filetype="pdf")
|
||
text = ""
|
||
for page in doc:
|
||
text += page.get_text()
|
||
doc.close()
|
||
# Send text instead of image
|
||
resp = self.oa.chat.completions.create(
|
||
model="gpt-4o",
|
||
messages=[
|
||
{"role": "system", "content": "You are an expert document analyser. Analyze the following invoice text and answer the question."},
|
||
{"role": "user", "content": f"Invoice text:\n{text}\n\nQuestion: {q}"}
|
||
],
|
||
max_tokens=300,
|
||
temperature=0.3,
|
||
)
|
||
return resp.choices[0].message.content
|
||
except Exception as e2:
|
||
print(f"DEBUG: Text extraction also failed: {e2}")
|
||
return "Error: Unable to process PDF document"
|
||
|
||
user_content: List[Dict[str, Any]] = [{"type": "text", "text": f"Parse the invoice and answer: {q}"}]
|
||
if img_uri:
|
||
user_content.append({"type": "image_url", "image_url": {"url": img_uri}})
|
||
print(f"DEBUG: Sending image to GPT-4o")
|
||
else:
|
||
print(f"DEBUG: No image available, sending text only")
|
||
|
||
resp = self.oa.chat.completions.create(
|
||
model="gpt-4o",
|
||
messages=[{"role": "system", "content": "You are an expert document analyser."},
|
||
{"role": "user", "content": user_content}],
|
||
max_tokens=300,
|
||
temperature=0.3,
|
||
)
|
||
return resp.choices[0].message.content
|
||
|
||
# ---------------- evaluation ---------------------
|
||
def run(self, invoices: List[Dict[str, Any]], questions: List[str]):
|
||
dataset = self.opik.get_or_create_dataset("GroundX vs GPT4o")
|
||
samples = []
|
||
for inv in invoices:
|
||
ctx = self._context(inv["xray_data"])
|
||
for q in questions:
|
||
expected_output = inv.get("expected_outputs", {}).get(q, "")
|
||
samples.append({
|
||
"input": f"Invoice {inv['name']} – {q}",
|
||
"context": ctx,
|
||
"invoice_name": inv["name"],
|
||
"expected_output": expected_output,
|
||
})
|
||
# Validation output for GEval parameters
|
||
print(f"DEBUG: Question='{q}', Expected='{expected_output}' for {inv['name']}")
|
||
|
||
dataset.clear(); dataset.insert(samples)
|
||
|
||
def gx_task(sample):
|
||
output = self._gpt_ctx(sample["input"], sample["context"])
|
||
print(f"DEBUG: GroundX output for '{sample['input']}': '{output}'")
|
||
return {"output": output}
|
||
|
||
def gpt_task(sample):
|
||
inv = next(i for i in invoices if i["name"] == sample["invoice_name"])
|
||
output = self._gpt_direct(sample["input"], inv["raw_bytes"], inv["mime_type"])
|
||
print(f"DEBUG: GPT-4o output for '{sample['input']}': '{output}'")
|
||
return {"output": output}
|
||
|
||
gx_results = []
|
||
gpt_results = []
|
||
|
||
for i, sample in enumerate(samples):
|
||
model_output = gx_task(sample)["output"]
|
||
expected_output = sample["expected_output"]
|
||
question = sample["input"]
|
||
|
||
gx_eval_result = evaluate_invoice_parsing(model_output, expected_output, question)
|
||
gx_results.append(gx_eval_result)
|
||
|
||
gpt_model_output = gpt_task(sample)["output"]
|
||
gpt_eval_result = evaluate_invoice_parsing(gpt_model_output, expected_output, question)
|
||
gpt_results.append(gpt_eval_result)
|
||
|
||
# Log evaluation results to Opik dataset
|
||
sample["groundx_score"] = gx_eval_result["overall_score"]
|
||
sample["groundx_reason"] = gx_eval_result["reason"]
|
||
sample["gpt4o_score"] = gpt_eval_result["overall_score"]
|
||
sample["gpt4o_reason"] = gpt_eval_result["reason"]
|
||
sample["groundx_output"] = model_output
|
||
sample["gpt4o_output"] = gpt_model_output
|
||
|
||
# Update dataset with evaluation results
|
||
dataset.clear()
|
||
dataset.insert(samples)
|
||
|
||
return {"groundx_parsing": gx_results, "gpt4o_direct": gpt_results, "dataset": dataset}
|
||
|
||
|
||
def create_evaluator_geval() -> EvaluatorGEval:
|
||
load_dotenv()
|
||
gx_key = os.environ.get("GROUNDX_API_KEY")
|
||
oa_key = os.environ.get("OPENAI_API_KEY")
|
||
comet_key = os.environ.get("COMET_API_KEY")
|
||
if not gx_key or not oa_key:
|
||
raise ValueError("API keys missing")
|
||
return EvaluatorGEval(gx_key, oa_key, comet_key)
|