dp-zp-agent/tools/rag/evaluation/evaluate_rag_answers.py

606 lines
15 KiB
Python

from __future__ import annotations
import argparse
import json
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parents[3]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(
0,
str(PROJECT_ROOT),
)
from tools.evaluation.rag_delivery import finalize_rag_answer
from tools.rag.evaluation.rag_answer_data import (
DEFAULT_DELAY,
apply_question_overrides,
questions_fingerprint as questions_fingerprint,
result_prefix,
select_questions,
validate_answer_questions,
validate_string_list as validate_string_list,
)
from tools.rag.evaluation.rag_answer_state import (
PARTIAL_SCHEMA_VERSION as PARTIAL_SCHEMA_VERSION,
build_base_result,
build_error_result,
build_partial_payload,
build_run_configuration,
determine_run_status,
extract_resume_state,
load_partial_payload,
ordered_results,
prepare_output_state,
print_summary,
save_csv_results,
save_json_results,
validate_resume_compatibility,
)
# These three explicit aliases preserve the evaluator's public helper API.
# Existing replay callers import them through this module.
from tools.rag.evaluation.rag_metrics import (
evaluate_answer,
summarize_results,
)
from tools.rag.evaluation.rag_runner import (
DEFAULT_BACKOFF_BASE,
DEFAULT_BACKOFF_MAX,
DEFAULT_MAX_ATTEMPTS,
DEFAULT_MODEL,
DEFAULT_TIMEOUT,
LOCAL_OPENAPI_URL,
build_rag_tool,
load_env_value,
request_json,
run_question,
)
from tools.rag.evaluation.retrieval_runner import (
load_questions,
)
EVALUATION_DIR = PROJECT_ROOT / "experiments/rag"
JSON_FILES_DIR = EVALUATION_DIR / "datasets"
QUESTIONS_PATH = JSON_FILES_DIR / "questions.json"
RAG_ANSWER_OVERRIDES_PATH = JSON_FILES_DIR / "rag_answer_overrides.json"
RESULTS_DIR = EVALUATION_DIR / "results"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=("Answer-level RAG evaluácia " "cez OpenWebUI, Model120 " "a ZP Agent.")
)
parser.add_argument(
"--split",
choices=(
"dev",
"test",
"all",
),
default="dev",
help=("Časť datasetu. " "Predvolené je dev."),
)
parser.add_argument(
"--limit",
type=int,
default=None,
help=("Maximálny počet otázok " "na vyhodnotenie."),
)
parser.add_argument(
"--model",
default=DEFAULT_MODEL,
help=("OpenWebUI model ID. " f"Predvolené: {DEFAULT_MODEL}."),
)
parser.add_argument(
"--questions",
type=Path,
default=QUESTIONS_PATH,
help=("Cesta k questions.json."),
)
parser.add_argument(
"--overrides",
type=Path,
default=RAG_ANSWER_OVERRIDES_PATH,
help=("Cesta k answer-level " "override súboru."),
)
parser.add_argument(
"--results-dir",
type=Path,
default=RESULTS_DIR,
help=("Adresár pre výsledky."),
)
parser.add_argument(
"--timeout",
type=int,
default=DEFAULT_TIMEOUT,
help=("HTTP timeout v sekundách. " f"Predvolené: {DEFAULT_TIMEOUT}."),
)
parser.add_argument(
"--max-attempts",
type=int,
default=DEFAULT_MAX_ATTEMPTS,
help=(
"Maximálny počet HTTP pokusov "
"pre retryable chyby. "
f"Predvolené: "
f"{DEFAULT_MAX_ATTEMPTS}."
),
)
parser.add_argument(
"--backoff-base",
type=float,
default=DEFAULT_BACKOFF_BASE,
help=(
"Počiatočný exponential "
"backoff v sekundách. "
f"Predvolené: "
f"{DEFAULT_BACKOFF_BASE}."
),
)
parser.add_argument(
"--backoff-max",
type=float,
default=DEFAULT_BACKOFF_MAX,
help=(
"Maximálny exponential "
"backoff v sekundách. "
f"Predvolené: "
f"{DEFAULT_BACKOFF_MAX}."
),
)
parser.add_argument(
"--delay",
type=float,
default=DEFAULT_DELAY,
help=("Pauza medzi otázkami " "v sekundách. " f"Predvolené: " f"{DEFAULT_DELAY}."),
)
parser.add_argument(
"--question-id",
action="append",
default=[],
help=("Vyhodnoť iba konkrétne ID. " "Parameter možno použiť " "opakovane."),
)
parser.add_argument(
"--fail-fast",
action="store_true",
help=("Pri prvej chybe " "ukonči evaluáciu."),
)
output_group = parser.add_mutually_exclusive_group()
output_group.add_argument(
"--resume",
action="store_true",
help=(
"Pokračuj z kompatibilného "
".partial.json. Úspešné otázky "
"sa preskočia a chybové sa "
"vyhodnotia znova."
),
)
output_group.add_argument(
"--overwrite",
action="store_true",
help=("Začni nový run a povoľ " "prepísanie existujúcich " "výsledkov."),
)
args = parser.parse_args()
if args.limit is not None and args.limit <= 0:
parser.error("--limit musí byť > 0")
if args.timeout <= 0:
parser.error("--timeout musí byť > 0")
if args.max_attempts <= 0:
parser.error("--max-attempts musí byť > 0")
if args.backoff_base < 0:
parser.error("--backoff-base " "nesmie byť záporné")
if args.backoff_max < 0:
parser.error("--backoff-max " "nesmie byť záporné")
if args.delay < 0:
parser.error("--delay nesmie byť záporné")
return args
def main() -> int:
args = parse_args()
try:
all_questions = load_questions(args.questions)
(
all_questions,
applied_override_ids,
) = apply_question_overrides(
all_questions,
args.overrides,
)
questions = select_questions(
all_questions,
split=args.split,
question_ids=(args.question_id),
limit=args.limit,
)
if not questions:
raise ValueError("Žiadne otázky " "nezodpovedajú filtru.")
validate_answer_questions(questions)
except (
FileNotFoundError,
ValueError,
json.JSONDecodeError,
) as exc:
print(
f"ERROR: {exc}",
file=sys.stderr,
)
return 2
selected_ids = {str(question["id"]) for question in questions}
selected_override_ids = [
question_id for question_id in applied_override_ids if question_id in selected_ids
]
prefix = result_prefix(
split=args.split,
limit=args.limit,
question_ids=(args.question_id),
)
json_path = args.results_dir / f"{prefix}.json"
csv_path = args.results_dir / f"{prefix}.csv"
partial_path = args.results_dir / f"{prefix}.partial.json"
try:
prepare_output_state(
json_path=json_path,
csv_path=csv_path,
partial_path=(partial_path),
resume=args.resume,
overwrite=args.overwrite,
)
except (
FileNotFoundError,
FileExistsError,
) as exc:
print(
f"ERROR: {exc}",
file=sys.stderr,
)
return 2
try:
openwebui_api_key = load_env_value(
"OPENWEBUI_API_KEY",
allow_env_file=False,
)
search_api_key = load_env_value("SEARCH_API_KEY")
except RuntimeError as exc:
print(
f"ERROR: {exc}",
file=sys.stderr,
)
return 2
try:
openapi = request_json(
LOCAL_OPENAPI_URL,
timeout=args.timeout,
max_attempts=(args.max_attempts),
backoff_base=(args.backoff_base),
backoff_max=(args.backoff_max),
)
(
operation_id,
rag_tool,
) = build_rag_tool(openapi)
except RuntimeError as exc:
print(
f"ERROR: {exc}",
file=sys.stderr,
)
return 2
configuration = build_run_configuration(
args=args,
questions=questions,
selected_override_ids=(selected_override_ids),
operation_id=(operation_id),
)
results_by_id: dict[
str,
dict[str, Any],
] = {}
previous_failed_ids: list[str] = []
if args.resume:
try:
partial_payload = load_partial_payload(partial_path)
validate_resume_compatibility(
partial_payload["configuration"],
configuration,
)
(
results_by_id,
previous_failed_ids,
) = extract_resume_state(
partial_payload,
selected_question_ids=(selected_ids),
)
except (
FileNotFoundError,
ValueError,
json.JSONDecodeError,
) as exc:
print(
f"ERROR: {exc}",
file=sys.stderr,
)
return 2
pending_questions = [
question for question in questions if str(question["id"]) not in results_by_id
]
print()
print("RAG answer evaluation")
print("=" * 60)
print(f"Dataset: " f"{args.questions}")
print(f"Overrides: " f"{args.overrides}")
print("Applied overrides: " f"{len(selected_override_ids)}")
if selected_override_ids:
print("Override IDs: " + ", ".join(selected_override_ids))
print(f"Model: " f"{args.model}")
print(f"Tool: " f"{operation_id}")
print(f"Split: " f"{args.split}")
print(f"Questions: " f"{len(questions)}")
print("Retry: " f"{args.max_attempts} " "pokusy")
print("Backoff: " f"{args.backoff_base}s " "→ max " f"{args.backoff_max}s")
print(f"Delay: " f"{args.delay}s")
if args.resume:
print("Resume: áno")
print(" úspešne obnovené: " f"{len(results_by_id)}")
print(" predchádzajúce chyby " "na retry: " f"{len(previous_failed_ids)}")
print(" zostáva spustiť: " f"{len(pending_questions)}")
else:
print("Resume: nie")
print("=" * 60)
print()
total = len(questions)
pending_total = len(pending_questions)
processed_pending = 0
stopped_early = False
for index, question in enumerate(
questions,
start=1,
):
question_id = str(question["id"])
question_text = str(question["question"])
if question_id in results_by_id:
print(f"[{index:04d}/" f"{total:04d}] " f"{question_id}: " "SKIP (resume)")
continue
print(f"[{index:04d}/" f"{total:04d}] " f"{question_id}: " f"{question_text}")
base_result = build_base_result(
question=question,
question_id=(question_id),
question_text=(question_text),
selected_override_ids=(selected_override_ids),
requested_model=(args.model),
)
try:
run_result = run_question(
question_text,
model=args.model,
operation_id=(operation_id),
rag_tool=rag_tool,
openwebui_api_key=(openwebui_api_key),
search_api_key=(search_api_key),
timeout=args.timeout,
max_attempts=(args.max_attempts),
backoff_base=(args.backoff_base),
backoff_max=(args.backoff_max),
)
run_result = finalize_rag_answer(run_result)
scores = evaluate_answer(
question,
run_result["answer"],
tool_called=bool(run_result["tool_called"]),
allowed_source_urls=run_result.get("rag_source_urls"),
)
result = {
**base_result,
**run_result,
**scores,
"strict_pass": bool(
scores["strict_pass"] and run_result.get("accepted_for_delivery", True)
),
"error": None,
}
print(
" tool:",
result["tool_called"],
"| answer:",
f"{result['answer_contains_score']:.2f}",
"| source:",
f"{result['source_url_score']:.2f}",
"| strict:",
result["strict_pass"],
"| latency:",
f"{result['total_latency_seconds']:.2f}s",
)
except Exception as exc:
result = build_error_result(
base_result,
exc,
)
print(
" ERROR:",
result["error"],
)
if args.fail_fast:
stopped_early = True
results_by_id[question_id] = result
processed_pending += 1
current_results = ordered_results(
questions,
results_by_id,
)
partial_payload = build_partial_payload(
status="partial",
configuration=(configuration),
results=(current_results),
expected_total=(total),
)
save_json_results(
partial_path,
partial_payload,
)
if stopped_early:
break
if args.delay > 0 and processed_pending < pending_total:
time.sleep(args.delay)
results = ordered_results(
questions,
results_by_id,
)
status = determine_run_status(
results,
expected_total=(total),
)
summary = summarize_results(results)
payload = {
"generated_at": (datetime.now(timezone.utc).isoformat()),
"status": status,
"configuration": (configuration),
"summary": summary,
"results": results,
}
save_json_results(
json_path,
payload,
)
save_csv_results(
csv_path,
results,
)
if status == "complete":
if partial_path.exists():
partial_path.unlink()
else:
save_json_results(
partial_path,
build_partial_payload(
status=status,
configuration=(configuration),
results=results,
expected_total=(total),
),
)
print_summary(summary)
print("Výsledky:")
print(f" JSON: " f"{json_path}")
print(f" CSV: " f"{csv_path}")
if partial_path.exists():
print(f" Partial: " f"{partial_path}")
print(f" Status: " f"{status}")
if summary["errors"]:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())