from __future__ import annotations import argparse import csv import json import time from datetime import datetime, timezone from pathlib import Path from typing import Any from ..graph_db import ( create_neo4j_driver, get_neo4j_settings, ) from ..graph_search import ( DEFAULT_LIMIT, DEFAULT_MAX_ENTITIES, search_graph_with_session, ) from .graphrag_evaluator import ( aggregate_by_field, aggregate_rows, evaluate_result, filter_questions, load_questions, ) from .reproducibility import graph_fingerprint, run_metadata, sha256_file, verify_freeze EVALUATION_DIR = Path( __file__ ).resolve().parent QUESTIONS_PATH = ( EVALUATION_DIR / "graphrag_questions_v4.json" ) RESULTS_DIR = ( EVALUATION_DIR / "results" ) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description=( "Vyhodnotenie GraphRAG vyhľadávania " "proti graphrag_questions.json." ) ) parser.add_argument( "--questions", type=Path, default=QUESTIONS_PATH, ) parser.add_argument( "--output-dir", type=Path, default=RESULTS_DIR / 'development_v4', ) parser.add_argument( "--split", choices=( "dev", "test", "all", ), default="dev", help=( "Predvolene sa vyhodnocuje iba DEV. " "TEST používaj až po zmrazení GraphRAG." ), ) parser.add_argument( "--limit", type=int, default=DEFAULT_LIMIT, ) parser.add_argument( "--max-entities", type=int, default=DEFAULT_MAX_ENTITIES, ) parser.add_argument( "--ids", type=str, default="", help=( "Voliteľný zoznam ID oddelených čiarkou." ), ) parser.add_argument( "--max-questions", type=int, default=None, help=( "Voliteľne vyhodnotí iba prvých N " "otázok z vybraného splitu." ), ) parser.add_argument('--audit', type=Path, help='Predeclared source-consistency audit.') parser.add_argument('--freeze-manifest', type=Path, help='Required for TEST/all; verifies code, data, graph and parameters.') parser.add_argument('--overwrite', action='store_true', help='Replace a previous DEV output; frozen TEST outputs are immutable.') args = parser.parse_args() if args.split in {'test', 'all'} and args.freeze_manifest is None: parser.error('TEST/all requires --freeze-manifest after complete DEV evaluation.') if args.split in {'test', 'all'} and (args.ids or args.max_questions): parser.error('Frozen holdout evaluation must cover the complete selected split.') if args.split in {'test', 'all'} and args.overwrite: parser.error('Use a fresh output directory to reproduce a frozen holdout.') if args.limit < 1: parser.error( "--limit musí byť aspoň 1." ) if args.max_entities < 1: parser.error( "--max-entities musí byť aspoň 1." ) if ( args.max_questions is not None and args.max_questions < 1 ): parser.error( "--max-questions musí byť aspoň 1." ) return args def save_json( path: Path, payload: dict[str, Any], ) -> None: path.parent.mkdir( parents=True, exist_ok=True, ) path.write_text( json.dumps( payload, ensure_ascii=False, indent=2, ) + "\n", encoding="utf-8", ) def save_csv( path: Path, rows: list[ dict[str, Any] ], ) -> None: path.parent.mkdir( parents=True, exist_ok=True, ) fieldnames = [ "complete", "answerability_ok", "bindings_ok", "binding_score", "paths_ok", "path_score", "count_ok", "expected_count", "actual_count", "id", "split", "category", "difficulty", "strict", "value_score", "entity_score", "relation_score", "property_score", "relation_choice_score", "document_recall", "source_recall", "entity_set_ok", "latency_seconds", "error", "question", "expected_values", "matched_values", "expected_relations", "actual_relations", "expected_documents", "actual_documents", "expected_source_urls", "actual_source_urls", "fact_types", "strategies", "binding_results", "path_results", ] with path.open( "w", encoding="utf-8", newline="", ) as file: writer = csv.DictWriter( file, fieldnames=fieldnames, lineterminator='\n', ) writer.writeheader() for row in rows: output = {} for field in fieldnames: value = row.get( field ) if isinstance( value, ( list, dict, ), ): value = json.dumps( value, ensure_ascii=False, ) output[ field ] = value writer.writerow( output ) def print_summary( summary: dict[str, Any], *, split: str, ) -> None: print() print( "=" * 78 ) print( "GRAPHRAG EVALUATION" ) print( "=" * 78 ) print( f"Split: {split}" ) print( f"Questions: " f"{summary['questions']}" ) print( f"Strict: " f"{summary['strict_count']}/" f"{summary['questions']} " f"({summary['strict_accuracy']:.3%})" ) print( f"Errors: " f"{summary['error_count']}" ) print( f"Value score: " f"{summary['value_score']:.3f}" ) print( f"Entity score: " f"{summary['entity_score']:.3f}" ) print( f"Relation score: " f"{summary['relation_score']:.3f}" ) print( f"Property score: " f"{summary['property_score']:.3f}" ) print( f"Relation choices: " f"{summary['relation_choice_score']:.3f}" ) print( f"Document recall: " f"{summary['document_recall']:.3f}" ) print( f"Source recall: " f"{summary['source_recall']:.3f}" ) print( f"Latency mean: " f"{summary['latency_mean_seconds']:.4f} s" ) print( f"Latency median: " f"{summary['latency_median_seconds']:.4f} s" ) print( f"Latency p95: " f"{summary['latency_p95_seconds']:.4f} s" ) print( "=" * 78 ) def validate_output_destination(args: argparse.Namespace) -> None: suffix = args.split if args.ids: suffix += '_selected' elif args.max_questions: suffix += f'_first{args.max_questions}' if not args.overwrite: for extension in ('json', 'csv'): path = args.output_dir / f'graphrag_{suffix}.{extension}' if path.exists(): raise RuntimeError(f'Results already exist; use a fresh output directory: {path}') elif (any(part.startswith(('final_', 'archive_')) for part in args.output_dir.parts) or any((args.output_dir / name).exists() for name in ('REPORT.md', 'summary.json', 'freeze.json'))): raise RuntimeError('Archived/final results are immutable; choose a fresh DEV directory.') def main() -> None: args = parse_args() validate_output_destination(args) metadata = run_metadata(args.questions) audit = json.loads(args.audit.read_text()) if args.audit else None if audit and (audit['dataset_sha256'] != metadata['fingerprints']['dataset_sha256'] or audit['inputs'] != metadata['fingerprints']['inputs']): raise RuntimeError('Source audit does not match current benchmark and inputs.') if audit and audit.get('source_corpus') is not None and ( audit['source_corpus'] != metadata['fingerprints']['source_corpus']): raise RuntimeError('Independent source audit does not match original Markdown.') all_questions = load_questions( args.questions ) ids = { value.strip() for value in args.ids.split( "," ) if value.strip() } questions = filter_questions( all_questions, split=args.split, ids=( ids if ids else None ), max_questions=( args.max_questions ), ) if not questions: raise RuntimeError( "Pre zadaný výber sa nenašli otázky." ) print() print( "GraphRAG dataset" ) print( "=" * 60 ) print( f"File: {args.questions}" ) print( f"Total: {len(all_questions)}" ) print( f"Split: {args.split}" ) print( f"Selected: {len(questions)}" ) print( f"Limit: {args.limit}" ) print( f"Entities: {args.max_entities}" ) print( "=" * 60 ) settings = ( get_neo4j_settings() ) rows: list[ dict[str, Any] ] = [] with create_neo4j_driver( settings ) as driver: driver.verify_connectivity() with driver.session( database=settings.database ) as session: metadata['graph'] = graph_fingerprint(session) if args.freeze_manifest: verify_freeze(json.loads(args.freeze_manifest.read_text()), metadata, metadata['graph'], limit=args.limit, max_entities=args.max_entities, audit=args.audit) total = len( questions ) for index, question in enumerate( questions, start=1, ): question_id = str( question[ "id" ] ) print( f"[{index:04d}/{total:04d}] " f"{question_id}: " f"{question['question']}" ) started = ( time.perf_counter() ) error = None try: result = ( search_graph_with_session( session, str( question[ "question" ] ), limit=args.limit, max_entities=( args.max_entities ), ) ) except Exception as exc: result = { "strategies": [], "resolved_entities": [], "paths": [], "facts": [], "documents": [], } error = ( f"{type(exc).__name__}: " f"{exc}" ) latency = ( time.perf_counter() - started ) row = evaluate_result( question, result, latency_seconds=( latency ), error=error, ) rows.append( row ) state = ( "OK" if row["strict"] else "FAIL" ) print( f" {state} | " f"value={row['value_score']:.2f} " f"entity={row['entity_score']:.2f} " f"rel={row['relation_score']:.2f} " f"doc={row['document_recall']:.2f} " f"{row['latency_seconds']:.4f}s" ) if graph_fingerprint(session) != metadata['graph']: raise RuntimeError('Graph changed during evaluation; results discarded.') if run_metadata(args.questions)['fingerprints'] != metadata['fingerprints']: raise RuntimeError('Code or input data changed during evaluation; results discarded.') metadata['completed_at_utc'] = datetime.now(timezone.utc).isoformat() if args.freeze_manifest: metadata['freeze_sha256'] = sha256_file(args.freeze_manifest) if args.audit: metadata['audit_sha256'] = sha256_file(args.audit) summary = aggregate_rows( rows ) by_category = aggregate_by_field( rows, "category", ) by_difficulty = ( aggregate_by_field( rows, "difficulty", ) ) payload = { 'evaluation_schema_version': 4, 'run_metadata': metadata, "configuration": { "questions_file": str( args.questions ), "split": args.split, "dataset_question_count": len( all_questions ), "selected_question_count": len( questions ), "limit": args.limit, "max_entities": ( args.max_entities ), "ids": sorted( ids ), "max_questions": ( args.max_questions ), }, "summary": summary, "by_category": by_category, "by_difficulty": ( by_difficulty ), "questions": rows, } if audit: consistent_ids = {row['id'] for row in audit['questions'] if row['source_consistent']} consistent = [row for row in rows if row['id'] in consistent_ids] payload['source_consistent_summary'] = aggregate_rows(consistent) payload['source_consistent_by_category'] = aggregate_by_field(consistent, 'category') payload['source_inconsistent_ids'] = [row['id'] for row in rows if row['id'] not in consistent_ids] suffix = args.split if ids: suffix += "_selected" elif args.max_questions: suffix += ( f"_first" f"{args.max_questions}" ) json_path = ( args.output_dir / f"graphrag_{suffix}.json" ) csv_path = ( args.output_dir / f"graphrag_{suffix}.csv" ) save_json( json_path, payload, ) save_csv( csv_path, rows, ) print_summary( summary, split=args.split, ) if audit: print_summary(payload['source_consistent_summary'], split=f'{args.split} — source-consistent subset (supplementary)') print(f"Source-inconsistent annotations: {len(payload['source_inconsistent_ids'])}; " 'included in the primary score above.') print() print( f"JSON: {json_path}" ) print( f"CSV: {csv_path}" ) if __name__ == "__main__": main()