diff --git a/CHANGELOG.md b/CHANGELOG.md index cd76685..50b7f77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added `oaeval doctor` warnings for configured providers whose optional extras are not installed. - Created `scripts/changelog_validator.py` to automatically verify Keep a Changelog formatting. - Added a section to `CONTRIBUTING.md` documenting the changelog update process for future contributors. +- Added `EvalPortReport` (`openagent_eval/reports/evalport.py`), a `ReportGenerator` that exports an `EvaluationReport` as an [EvalPort](https://github.com/adhabnr-ux/evalport) `ResultSet` for interop with other EvalPort-speaking evaluation tools; install with the new optional `evalport` extra (design agreed in Discussion #296). + +### Fixed +- **EvalPortReport double-counting failed items** — `evaluation_report_to_result_set` no longer builds a second failed `Result` from `PipelineResult.errors` on top of the zeroed `EvaluationResult` already present in `PipelineResult.results`, which was silently corrupting `summary.total`/`summary.pass_rate` for any run with failures. Failed items are now sourced from `results` alone (via `metadata["failed"]`), which also sidesteps an ordering hazard: `errors` is appended to from inside each item's own coroutine and reflects completion order under the parallel executor, not dataset order, so it was never safe to zip against `results` by index. `Pipeline._evaluate_item`'s failure path also now preserves the source item's `metadata` (matching the success path) and records `error_type`, so a failed item's `test_case_id`/custom metadata survive the same way a successful item's do. (review feedback from @himanshu231204 on PR #369) --- @@ -35,537 +39,3 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), --- -## [0.4.9] - 2026-08-17 - -### Added - -- **EvaluationReport Type Hints** — add `EvaluationReport` type hints to report generators for better IDE support (#239) -- **Common Evaluation Workflow Examples** — add example workflows documentation for common RAG evaluation patterns (#289) -- **Config Reference Documentation** — new `config-reference.md` documenting the full YAML schema -- **CLI Exit Code Documentation** — document CLI exit codes for programmatic usage (#107) -- **LLM API Key Setup Guide** — new docs page for configuring API keys -- **Plugin Development Guide** — comprehensive guide for building custom plugins -- **End-to-End Tutorial** — tutorial with free APIs and local embeddings -- **Colab Tutorial Notebook** — zero-setup Colab tutorial for quick onboarding - -### Fixed - -- **ROUGE Recall Fallback** — compute occurrence-based ROUGE recall fallback when standard ROUGE fails (#288) -- **LatencyMetric None Crash** — handle `None` latency_ms in `LatencyMetric` without crashing (#286) -- **Exact Match Whitespace** — normalize internal whitespace in exact match (#281) -- **Falsy Exception Details** — record explicitly-supplied falsy details instead of dropping them (#280) -- **Short Answer False Positives** — require low relevancy to flag short answers as off-topic (#66) -- **Silent Retrieval Failures** — surface retrieval failures in logs and run errors (#256) -- **CICD Gate Metrics Summary** — populate `metrics_summary` in `run_evaluation` gate summary (#228) -- **CICD Event Loop** — make `run_evaluation` independent of event-loop policy state -- **CICD Lazy pytest** — lazy-load pytest in plugin.py (#227) -- **Config SecretStr** — store `LLMConfig.api_key` as `SecretStr` for security -- **Config Exports** — export `CorpusConfig`, `ReportConfig`, and `OutputFormat` -- **Pipeline Ground Truth** — detect `ground_truth_contexts` support for backward compatibility -- **Pipeline Retriever Contract** — pass `ground_truth_contexts` through public retriever contract -- **Pipeline LLM Model** — read LLM model through public `model_name` contract -- **CLI Config Loading** — load config exactly once in `run_command` -- **Executor Async** — async `run_in_thread`, cancel siblings on timeout, lazy thread pool -- **Corpus Unknown Checks** — raise `CorpusValidationError` for unknown audit checks -- **Corpus Timestamp UTC** — make `AuditReport.timestamp` UTC-aware -- **Corpus Concurrency** — limit contradiction detector concurrency -- **Diagnosis Content Gaps** — use whole-word matching for content gaps -- **NDCG Off-by-One** — fix off-by-one DCG position discount (#223) -- **JSONL max_documents** — enforce `max_documents` cumulatively across `.jsonl` files (#224) -- **Synthesis Config Migration** — fix legacy config migration bugs in `loader.py` - -### Changed - -- **Retrieval Failure Surfacing** — make retrieval-failure surfacing log-only for non-critical cases (#256) -- **Synthesis Response Parser** — use shared response parser in `question_gen` and `adversarial` - -### Removed - -- **Dead Executor Methods** — remove `Executor.execute_parallel()` and `Executor.execute_sequential()`, which had no production callers (the pipeline uses `Executor.gather()`); their only in-tree references were two dedicated unit tests (#52) - -### Documentation - -- **MkDocs Theme Redesign** — dark-first, org brand, hero + cards (#239) -- **README Banner** — professional README banner for social sharing (#236) -- **README Rewrite** — community-focused README with OpenAgentHQ branding -- **Notebook Refresh** — refresh notebooks to v0.4.8 and wire tutorials into docs site -- **CLI Wizard Screenshot** — add interactive CLI wizard screenshot to README -- **ExactMatch Docs** — align documentation with actual behavior - -### Testing - -- **Diagnose Command Tests** — add unit tests for diagnose command -- **CICD Regression Tests** — strengthen regression tests for gate metrics_summary (#228) -- **Synthesis Parser Tests** — add characterization tests for question_gen and adversarial parsers -- **Short-Answer Regression Tests** — regression tests for short-answer false positives (#66) -- **Retrieval Failure Tests** — regression test for silent retrieval-failure swallow (#256) -- **Corpus Concurrency Tests** — strengthen concurrency limit assertion -- **Exception Falsy Tests** — cover falsy original errors - -### Contributors - -- ❤️ @Nitjsefnie -- ❤️ @Nithyaviswak -- ❤️ @PrinceThummar011 -- ❤️ @himanshu231204 - ---- - -## [0.4.8] - 2026-07-24 - -### Fixed - -- **Missing Reports Subpackage in sdist** — anchor `reports/` in `.gitignore` to root-only and add explicit sdist include for `openagent_eval/reports/`; 0.4.7 wheel/sdist shipped without the `reports` subpackage, making all CLI commands fail at import (#233) - ---- - -## [0.4.7] - 2026-07-24 - -### Fixed - -- **Mock Provider Regression Test** — add regression test verifying that `--llm-provider mock` never falls back to `OpenAIProvider` (#40) -- **Chunking Metadata Usage** — `ChunkingQualityAnalyzer.analyze()` now uses the `metadata` parameter to perform more informed analysis: checks chunk size deviation from expected, detects excessive character overlap, and adjusts empty chunk thresholds (#65) - ---- - -## [0.4.6] - 2026-07-17 - -### Added - -- **Retriever Settings Validation** — validate retriever settings keys to catch typos early (#172) -- **Standardized Provider Errors** — standardize provider error `__str__` format for better debugging (#167) -- **Environment Variables Reference** — new docs page covering all environment variables (#176) -- **Package Release Rules** — add release workflow documentation to AGENT.md (#183) - -### Fixed - -- **pgvector Async Connection** — use async psycopg connection in pgvector retriever (#182) -- **Report max_examples Respect** — HTML and JSON generators now respect `ReportConfig.max_examples` (#181) -- **Synthesis Duplicate Functions** — extract duplicate inner generation functions (#180) -- **Synthesis Parallel Generation** — parallelize adversarial test case generation with `asyncio.gather` (#178) -- **Synthesis Premature Return** — fix Strategy 0 in `_parse_response` returning prematurely (#175) -- **Synthesis Corpus Errors** — `_read_corpus` no longer silently swallows file reading errors (#149) -- **CLI Version Flag** — add consistent `--version` flag to all CLI commands (#157) -- **CLI Export Commands** — export all CLI commands (#147) -- **CLI Dry-run Warning** — interpolate timeout in dry-run warning (#145) -- **CLI Mock Provider** — use configured provider for synth (#148) -- **Progress Bar Reset** — fix progress bar reset to 1/total_items on completion (#162) -- **Context Variable Shadowing** — rename ctx variables to avoid shadowing CLIContext and click.Context (#153, #162) -- **Report max_examples Hardcoded** — use `ReportConfig.max_examples` instead of hardcoded limits (#161) -- **Comparison Winner Logic** — use common metrics only in comparison report (#159) -- **Dataset Input Validation** — add input validation for dataset path with helpful error message (#158) -- **Non-ASCII Keywords** — support non-ASCII characters in content gap analysis (#156) -- **Failure Analysis Metrics** — pass `metric_scores` from error entries in `_compute_failure_analysis` (#155) -- **Synthesis Curly Braces** — escape curly braces in context before `str.format()` in synthesis (#152) -- **Report Config Validation** — validate config key in `ReportManager.reconstruct()` (#150) -- **Metrics Zero Timeout** — preserve zero timeout details (#146) -- **Missing Command Imports** — add missing imports and `__all__` entries for 6 commands (#168) -- **Orphan LLMResponse** — remove orphan LLMResponse construction in anthropic generate() (#166) -- **Dead Regex** — remove dead `_SIMPLE_PATTERNS` regex (#165) -- **Corpus Naive Timestamps** — normalize naive staleness timestamps (#170) -- **Configuration Validation** — enhance error messages for missing required config fields (#140) - -### Changed - -- **Env-var Documentation** — correct env-var and config claims in docs (#179) - -### Documentation - -- **Quickstart Guide** — add QUICKSTART.md for coding agents (#144) -- **Context Files Compressed** — compress context files to <100 lines for coding agent efficiency (#142) -- **AI Files Reorganized** — move .ai/ files to root, add INSTRUCTIONS.md writing rules (#139) - -### Testing - -- **Synth CLI Unit Tests** — add unit tests for the synth CLI command (#174) -- **Report Edge Cases** — cover `ReportManager.reconstruct()` edge cases (#173) -- **Pipeline Integration Test** — add full-pipeline e2e test with mock providers (#171) - -### Internal - -- **Ignore Local Configs** — ignore local config files in git (#143) -- **Remove Local Artifacts** — remove local artifacts from tracking (#141) - -### Contributors - -- ❤️ @himanshu231204 -- ❤️ @Nitjsefnie -- ❤️ @fazalpsinfo-cmyk -- ❤️ @Sanjays2402 -- ❤️ @lesbass -- ❤️ @PrinceThummar011 -- ❤️ @Silvren -- ❤️ @hkJerryLeung -- ❤️ @1-gokul - ---- - -## [0.4.5] - 2026-07-15 - -### Added - -- **Issue Claim System** — production-ready issue claim workflow replacing broken auto-assign -- **PR Congratulations Workflow** — automated congratulations on merged PRs -- **Reports Output Formats Documentation** — new docs page covering report output formats - -### Fixed - -- **JSONL Corpus Loading** — JSONL files now load as one document per line in corpus auditor -- **Unused Imports** — removed unused imports across the codebase - -### Changed - -- **README Rewrite** — professional layout with GitHub badges (Stars, Forks, Contributors) - ---- - -## [0.4.4] - 2026-07-12 - -### Fixed - -- **Synthesis JSON Parsing** — add individual JSON object parsing for malformed responses -- **Synthesis Notebook** — update notebook with v0.4.4 and no hardcoded API key - ---- - -## [0.4.3] - 2026-07-12 - -### Fixed - -- **Synthesis JSON Parsing** — simplify JSON parsing with multi-strategy fallback - ---- - -## [0.4.2] - 2026-07-12 - -### Fixed - -- **Synthesis JSON Parsing** — add regex fallback for JSON parsing in synthesis module - ---- - -## [0.4.1] - 2026-07-12 - -### Fixed - -- **Synthesis JSON Parsing** — improve JSON parsing resilience in question_gen - ---- - -## [0.4.0] - 2026-07-12 - -### Added - -- **Phase 13: CI/CD Integration** - - CI/CD module with workflow management - - Unit tests for CI/CD module (35 tests) - -- **Phase 14: TUI Redesign (Partial)** - - Claude Code-inspired TUI components - - Rich command input with autocomplete - - Virtual scrolling message list - - OAEVAL block-style ASCII art banner - -### Changed - -- **TUI Removal** — removed TUI dashboard, keeping CLI-only interface -- **README Badges** — updated badges and uv.lock dependencies -- **Documentation** — removed all TUI/Textual references - -### Fixed - -- **ChromaDB Tests** — resolve ChromaDB test mock setup and normalize_distance tests -- **CLI Tests** — fix CLI test assertions and eval workflow audit command - ---- - -## [0.3.0] - 2026-07-11 - -### Added - -- **Phase 7: CLI Commands (Complete)** - - `oaeval init` — interactive wizard for provider/model selection - - `oaeval run` — evaluation pipeline with dry-run mode and metrics override - - `oaeval report` — view evaluation reports - - `oaeval compare` — compare two experiments - - `oaeval list` — list evaluations with sorting (date/score/cost) and search filtering - - `oaeval doctor` — environment check with API connectivity tests - - `oaeval validate` — config validation without running evaluation - - `oaeval delete` — remove old reports - - `oaeval diagnose` — diagnose failures and attribute blame - - `oaeval audit` — audit corpus health - - `oaeval synth` — generate synthetic test cases - - Shell completion for bash, zsh, and fish - - Global flags: `--quiet`, `--json`, `--no-color`, `--verbose` - - Config auto-discovery (config.yaml/oaeval.yaml in cwd, OAEVAL_CONFIG env var) - -- **Phase 8: Documentation (Complete)** - - Vision documentation (docs/01_vision.md) - - Problem statement (docs/02_problem_statement.md) - - Product requirements (docs/03_product_requirements.md) - - Architecture documentation (docs/04_architecture.md) - - Project structure (docs/05_project_structure.md) - - CLI specification (docs/06_cli_spec.md) - - Metric system documentation (docs/07_metric_system.md) - - Plugin system documentation (docs/08_plugin_system.md) - - Coding guidelines (docs/09_coding_guidelines.md) - - Development plan (docs/10_development_plan.md) - - Future roadmap (docs/11_future_roadmap.md) - - Retriever providers documentation (docs/12_retrievers.md) - - CONTRIBUTING.md, ROADMAP.md, CHANGELOG.md - - CODE_OF_CONDUCT.md, SECURITY.md, SUPPORT.md, DEVELOPMENT.md - - GitHub issue templates (bug report, feature request) - - GitHub pull request template - -- **Phase 9: Corpus Health Auditor** - - `CorpusAuditor` — orchestrates all corpus health analyzers - - `ContradictionDetector` — cross-document contradiction detection - - `StalenessDetector` — unmarked obsolescence detection - - `DuplicateDetector` — divergent duplicate detection - - `CoverageAnalyzer` — thematic coverage analysis - - `CorpusIssue`, `AuditReport`, `IssueType`, `IssueSeverity` models - - `oaeval audit` CLI command with configurable checks - - Unit and integration tests - -- **Phase 10: Component Diagnosis** - - `DiagnosisAnalyzer` — orchestrates failure diagnosis - - `BlameAttribution` — blame attribution engine (retrieval vs generation vs chunking) - - `ChunkingQualityAnalyzer` — chunking quality analysis - - 8 failure mode detection - - Actionable recommendations - - `BlameResult`, `BlameTarget`, `ChunkingIssue`, `ComponentScores` models - - `DiagnosisReport`, `FailureInstance`, `FailureMode` models - - `oaeval diagnose` CLI command - - Unit and integration tests - -- **Phase 11: Synthetic Test Data** - - `SyntheticDataGenerator` — main generator orchestrator - - `QuestionGenerator` — question generation from documents - - `AdversarialTestCaseGenerator` — adversarial test case generation - - `SyntheticDataset`, `TestCase`, `TestCaseType` models - - `oaeval synth` CLI command - - Unit and integration tests - -- **Phase 12: Advanced Providers & NLI Metrics** - - **Retriever Providers (11 total):** - - ChromaDB, Qdrant, Pinecone, Weaviate, FAISS, pgvector - - Elasticsearch, BM25 (lexical), HTTP (generic REST), Memory (in-memory), Mock - - **Embedder Abstraction:** - - `Embedder` base interface - - Sentence Transformers embedder (all-MiniLM-L6-v2) - - Mock embedder for offline testing - - **Score Normalization:** - - `normalize_distance`, `minmax_normalize`, `rank_based_normalize` helpers - - Unified `[0.0, 1.0]` score range across all backends - - **NLI Metrics:** - - `NLIJudge` — DeBERTa-based NLI scoring - - `ClaimExtractor` — split answers into atomic claims - - `EvidenceFinder` — match claims to supporting context via NLI - - **PDF Dataset Loader** — PDF document loading support - - Provider factory with lazy loading - - Unit tests for all providers and embedders - ---- - -## [0.2.0] - 2026-07-10 - -### Added - -- **CLI Improvements** - - Global error handler with friendly Rich output for `OpenAgentEvalError` subclasses - - Global flags: `--quiet`, `--json`, `--no-color`, `--verbose` - - Config auto-discovery (config.yaml/oaeval.yaml in cwd, OAEVAL_CONFIG env var) - - New `validate` command to check config without running evaluation - - Dry-run mode (`--dry-run` flag on run command) - - Shell completion support for bash, zsh, and fish - - Enhanced `doctor` command with API connectivity tests - - New `delete` command for removing old reports - - Enhanced `list` command with sorting (date/score/cost) and search filtering - - Enhanced `init` command with interactive wizard for provider/model selection - - JSON output support for all commands (`--json` flag) - -- Phase 8: Documentation - - Vision documentation - - Problem statement - - Product requirements - - Architecture documentation - - Project structure - - CLI specification (updated with new commands and features) - - Metric system documentation - - Plugin system documentation - - Coding guidelines - - Development plan - - Future roadmap - - Examples - - CONTRIBUTING.md - - ROADMAP.md - - CHANGELOG.md -- CONTRIBUTING.md with contribution guidelines -- CODE_OF_CONDUCT.md (Contributor Covenant v2.0) -- SECURITY.md with vulnerability reporting process -- SUPPORT.md with support channels -- DEVELOPMENT.md with development guide -- GitHub issue templates (bug report, feature request) -- GitHub pull request template - -### Changed - -- Improved README.md with badges and comprehensive documentation -- Updated CLI documentation with new commands and features -- Enhanced `init` command with interactive wizard -- Enhanced `run` command with dry-run mode and metrics override -- Enhanced `doctor` command with API connectivity tests -- Enhanced `list` command with sorting and filtering -- Improved error handling across all CLI commands - -### Fixed - -- Fixed error chaining in CLI commands (raise from) -- Fixed unused imports and variables - ---- - -## [0.1.0] - 2026-07-08 - -### Added - -- **Phase 1: Foundation** - - Project initialization with `uv` - - `pyproject.toml` with all dependencies - - Directory structure (`openagent_eval/*`) - - Exception hierarchy (`exceptions/*`) - - CLI skeleton with Typer - - Configuration system (Pydantic v2 + YAML) - - Core module (`engine.py`, `pipeline.py`, `executor.py`, `registry.py`) - - Testing infrastructure (pytest) - - Linting and formatting (ruff) - -- **Phase 2: Data Layer** - - `BaseDatasetLoader` interface - - JSON dataset loader - - JSONL dataset loader - - CSV dataset loader - - HuggingFace dataset loader - - Dataset validation (Pydantic models) - - Dataset schema enforcement - -- **Phase 3: Metrics System** - - `BaseMetric` interface - - `MetricResult` model - - Retrieval metrics: - - Context Precision - - Context Recall - - Recall@K - - Precision@K - - Hit Rate - - Mean Reciprocal Rank (MRR) - - NDCG - - Generation metrics: - - Faithfulness (Ragas integration) - - Answer Relevancy (Ragas integration) - - Hallucination Detection (DeepEval integration) - - Semantic Similarity (Sentence Transformers) - - Exact Match - - F1 Score - - BLEU (HF Evaluate) - - ROUGE (HF Evaluate) - - BERTScore - - Performance metrics: - - Latency tracking - - Cost metrics: - - Token counting - - Cost estimation - - Unit tests (86 tests) - -- **Phase 4: Reports System** - - `ReportGenerator` interface - - Terminal report (Rich) - - Markdown report - - HTML report (Jinja2) - - JSON report - - Failure analysis reporting - - Experiment comparison reports - - Unit tests (78 tests) - -- **Phase 5: Providers** - - `LLMProvider` interface - - `Retriever` interface - - OpenAI adapter - - Gemini adapter - - Anthropic adapter - - Groq adapter - - OpenRouter adapter - - Ollama adapter (token tracking only) - - Chroma retriever adapter - - Unit tests (138 tests) - -- **Phase 6: Plugin System** - - Plugin registry - - Entry point discovery - - Plugin loading mechanism - - Plugin development guide - - Example custom metric plugin - - Unit tests (27 tests) - -- Initial release -- CLI interface with Typer (`oaeval init`, `run`, `report`, `compare`, `list`, `doctor`) -- SDK for programmatic usage -- Configuration system with Pydantic models and YAML support -- Plugin architecture for custom metrics, providers, and report generators -- Retrieval metrics: Context Precision, Context Recall, Recall@K, Precision@K, Hit Rate, MRR, NDCG -- Generation metrics: Faithfulness, Answer Relevancy, Hallucination Detection, Semantic Similarity, Exact Match, F1, BLEU, ROUGE, BERTScore -- Performance metrics: Embedding latency, Retrieval latency, LLM latency, Total latency -- Cost metrics: Token counting, Cost estimation -- LLM providers: OpenAI, Google Gemini, Anthropic, Groq, OpenRouter, Ollama -- Retriever providers: Chroma -- Report formats: Terminal, Markdown, HTML, JSON -- Dataset loaders for JSON and CSV formats -- Custom exception hierarchy -- Comprehensive test suite with pytest - -### Technical Details - -- Python 3.11+ required -- Built with Typer + Rich for CLI -- Pydantic v2 for validation -- asyncio for parallel execution -- Plugin-based architecture -- 517+ tests passing - ---- - -## [0.0.1] - 2026-07-08 - -### Added - -- Initial project structure -- Basic documentation -- Architecture decisions (D001-D016) - ---- - -## Versioning - -This project follows [Semantic Versioning](https://semver.org/): - -- **MAJOR**: Incompatible API changes -- **MINOR**: Backward-compatible new functionality -- **PATCH**: Backward-compatible bug fixes - -## Links - -[Unreleased]: https://github.com/openagenthq/openagent-eval/compare/v0.4.10...HEAD -[0.4.10]: https://github.com/openagenthq/openagent-eval/compare/v0.4.9...v0.4.10 -[0.4.9]: https://github.com/openagenthq/openagent-eval/compare/v0.4.8...v0.4.9 -[0.4.8]: https://github.com/openagenthq/openagent-eval/compare/v0.4.7...v0.4.8 -[0.4.7]: https://github.com/openagenthq/openagent-eval/compare/v0.4.6...v0.4.7 -[0.4.6]: https://github.com/openagenthq/openagent-eval/compare/v0.4.5...v0.4.6 -[0.4.5]: https://github.com/openagenthq/openagent-eval/compare/v0.4.4...v0.4.5 -[0.4.4]: https://github.com/openagenthq/openagent-eval/compare/v0.4.3...v0.4.4 -[0.4.3]: https://github.com/openagenthq/openagent-eval/compare/v0.4.2...v0.4.3 -[0.4.2]: https://github.com/openagenthq/openagent-eval/compare/v0.4.1...v0.4.2 -[0.4.1]: https://github.com/openagenthq/openagent-eval/compare/v0.4.0...v0.4.1 -[0.4.0]: https://github.com/openagenthq/openagent-eval/compare/v0.3.0...v0.4.0 -[0.3.0]: https://github.com/openagenthq/openagent-eval/compare/v0.2.0...v0.3.0 -[0.2.0]: https://github.com/openagenthq/openagent-eval/compare/v0.1.0...v0.2.0 -[0.1.0]: https://github.com/openagenthq/openagent-eval/releases/tag/v0.1.0 diff --git a/docs/reports-output-formats.md b/docs/reports-output-formats.md index 5314c55..f167b9a 100644 --- a/docs/reports-output-formats.md +++ b/docs/reports-output-formats.md @@ -350,7 +350,79 @@ oaeval report latest --output json --- -## 5. Comparison Report +## 5. EvalPort Report + +The **EvalPort Report** (`EvalPortReport`) exports a completed evaluation as an [EvalPort](https://github.com/adhabnr-ux/evalport) `ResultSet` -- an open, tool-agnostic JSON format for evaluation results shared across DeepEval, Promptfoo, Inspect AI, AutoGen, CrewAI, Ragas, LangSmith, Braintrust, MLflow, Opik, TruLens, and now OpenAgent Eval. Use it to hand a run's results to any EvalPort-speaking dashboard, comparison tool, or CI gate without writing a bespoke exporter. + +Unlike Terminal/Markdown/HTML/JSON, this generator is not wired into `--output` yet -- OpenAgent Eval's metrics are bare `[0.0, 1.0]` scores with no native pass/fail concept, so producing an EvalPort `ResultSet` (which requires a `passed` boolean per grader) needs a threshold decision that has no single obviously-correct default for every metric. Invoke it directly from the SDK once you have an `EvaluationReport`: + +```python +from openagent_eval.reports.evalport import EvalPortReport + +# Optional: pass-thresholds per metric name. Any metric not listed here +# uses default_threshold (0.5). +generator = EvalPortReport(evalport_thresholds={"faithfulness": 0.7}) +generator.generate_to_file(report, "result_set.json") +``` + +### Mapping + +* **`metrics` → `grader_results`**: one `GraderResult` per metric (`grader_id` = metric name, `type="custom"`). `passed` is derived from `evalport_thresholds` (default `0.5`) since OpenAgent Eval's metrics carry no native pass/fail -- every result's `metadata.openeval_derived_pass` is set to `true` so a consumer can always tell an inferred pass/fail from a tool-native one. +* **`answer` → `actual_output`**, **`metadata["latency_ms"]` → `duration_ms`** (rounded to the nearest millisecond). +* **`question` / `ground_truth` / `contexts`**: preserved under `metadata.openagent_eval`, since EvalPort's `Result` schema has no dedicated fields for them. +* **`test_case_id`**: the dataset item's `metadata.id` when present, else a positional `f"{run_id}_item_{i}"` (`i` is the item's index in `PipelineResult.results`, which preserves dataset order). +* **Failed items**: `Pipeline._evaluate_item` flags a retrieval/generation/metric failure two ways -- it appends a dict to `PipelineResult.errors`, *and* it returns a zeroed `EvaluationResult` (`metadata["failed"] = True`) that lands in `PipelineResult.results` like every other item. This adapter reads failures from `results` alone: an item with `metadata["failed"]` set becomes a failed `Result` (`grader_results: []`, `passed: false`, `error` populated from `metadata["error"]`/`metadata["error_type"]`) and `PipelineResult.errors` is never walked. Sourcing both would double-count every failure (one `Result` from the zeroed item, a second synthetic one from the matching `errors` entry) -- and `errors` isn't safe to zip against dataset position anyway, since it's appended to from inside each item's own coroutine and so reflects completion order under the parallel executor, not dataset order. +* **Direction**: strictly one-way (`EvaluationReport -> ResultSet`). There is no `from_openeval` -- OpenAgent Eval's own dataset loading already has an established shape this adapter does not replace. + +### Sample Output + +```json +{ + "version": "1.0.0-rc.5", + "suite_id": "data/questions.json", + "run_id": "run_2026-07-14T12-30", + "started_at": "2026-07-14T12:30:48Z", + "results": [ + { + "test_case_id": "1", + "grader_results": [ + {"grader_id": "precision", "type": "custom", "score": 0.95, "passed": true, "reason": "precision=0.9500 (threshold=0.5)"}, + {"grader_id": "recall", "type": "custom", "score": 0.88, "passed": true, "reason": "recall=0.8800 (threshold=0.5)"}, + {"grader_id": "faithfulness", "type": "custom", "score": 0.92, "passed": true, "reason": "faithfulness=0.9200 (threshold=0.7)"} + ], + "passed": true, + "metadata": { + "openagent_eval": { + "question": "What is Python?", + "ground_truth": "Python is a high-level programming language.", + "contexts": ["Python is a programming language created by Guido van Rossum."] + }, + "openeval_derived_pass": true + }, + "actual_output": "Python is a programming language.", + "duration_ms": 842 + }, + { + "test_case_id": "run_2026-07-14T12-30_item_1", + "grader_results": [], + "passed": false, + "error": {"message": "Connection timeout", "detail": "ProviderConnectionError"}, + "metadata": {"openeval_derived_pass": true, "openagent_eval": {"question": "Failed question"}} + } + ], + "summary": {"total": 2, "passed": 1, "failed": 1, "pass_rate": 0.5}, + "metadata": { + "openagent_eval": {"engine": "openagent-eval", "version": "0.1.0", "title": "OpenAgent Eval Report"} + }, + "completed_at": "2026-07-14T12:30:49Z" +} +``` + +Validate any generated `ResultSet` against the spec with `openeval.validate.validate_result_set()` (from the optional `evalport-sdk` dependency, `pip install openagent-eval[evalport]`). + +--- + +## 6. Comparison Report The **Comparison Report** (`ComparisonReport`) is a dedicated utility used to compare two experiments side by side. It calculates the delta for each shared metric, assesses whether the overall score went up or down, and declares a winner. diff --git a/openagent_eval/core/pipeline.py b/openagent_eval/core/pipeline.py index f0e9cc9..2fb6852 100644 --- a/openagent_eval/core/pipeline.py +++ b/openagent_eval/core/pipeline.py @@ -167,7 +167,12 @@ async def _evaluate_item( ground_truth=ground_truth, contexts=[], metrics={name: 0.0 for name, _ in self._metrics}, - metadata={"failed": True, "error": str(e)}, + metadata={ + "failed": True, + "error": str(e), + "error_type": type(e).__name__, + **item.get("metadata", {}), + }, ) # ------------------------------------------------------------------ # diff --git a/openagent_eval/reports/__init__.py b/openagent_eval/reports/__init__.py index bc32480..1c32645 100644 --- a/openagent_eval/reports/__init__.py +++ b/openagent_eval/reports/__init__.py @@ -5,6 +5,7 @@ - Markdown: Structured .md files - HTML: Styled web pages via Jinja2 - JSON: Machine-readable structured data +- EvalPort: Portable ResultSet JSON (https://github.com/adhabnr-ux/evalport) - Comparison: Side-by-side experiment comparison """ @@ -16,6 +17,7 @@ ReportInput, ) from openagent_eval.reports.comparison import ComparisonReport +from openagent_eval.reports.evalport import EvalPortReport from openagent_eval.reports.html import HTMLReport from openagent_eval.reports.json_report import JSONReport from openagent_eval.reports.markdown import MarkdownReport @@ -31,5 +33,6 @@ "MarkdownReport", "HTMLReport", "JSONReport", + "EvalPortReport", "ComparisonReport", ] diff --git a/openagent_eval/reports/evalport.py b/openagent_eval/reports/evalport.py new file mode 100644 index 0000000..e9256a1 --- /dev/null +++ b/openagent_eval/reports/evalport.py @@ -0,0 +1,506 @@ +"""EvalPort report generator. + +EvalPort (https://github.com/adhabnr-ux/evalport) is an open interchange +format (Apache 2.0) for portable LLM evaluation datasets: test cases, +graders, suites, and results as plain JSON, shared across evaluation tools +(DeepEval, Promptfoo, Inspect AI, AutoGen, CrewAI, and others). + +This module converts a completed :class:`~openagent_eval.core.engine.EvaluationReport` +into an EvalPort ``ResultSet`` (a plain ``dict`` matching the schema validated by +``openeval.validate.validate_result_set``). The direction is strictly +one-directional for v1 (``EvaluationReport -> ResultSet``); there is no +``from_openeval`` here, since OpenAgent Eval's own dataset/config loading +already has its own well-established shape (see ``config/loader.py`` and +``cli/commands/run.py``) that this adapter does not attempt to replace. + +Design notes (agreed in OpenAgentHQ/openagent-eval Discussion #296 with +@himanshu231204): + +- OpenAgent Eval's :class:`~openagent_eval.core.pipeline.EvaluationResult` + has no native pass/fail concept -- only a ``metrics: dict[str, float]`` + of scores, each already validated to ``[0.0, 1.0]`` by + ``MetricResult.__post_init__``. EvalPort's ``GraderResult.passed`` is + required, so pass/fail is *derived* here via an optional + ``evalport_thresholds`` mapping (``metric name -> threshold``), defaulting + to ``0.5`` for any metric not explicitly listed. Because this pass/fail + judgment is synthesized rather than native to the source data, every + derived result is flagged transparently via + ``metadata["openeval_derived_pass"] = True`` on that result -- a consumer + that cares about the distinction between "the tool told us this passed" + and "we inferred a pass from a threshold" can always tell which is which. +- ``test_case_id`` reads the dataset item's optional ``id`` field (preserved + by the pipeline into ``EvaluationResult.metadata["id"]`` -- see + ``Pipeline._evaluate_item``'s ``**item.get("metadata", {})`` spread) and + falls back to the positional ``f"{run_id}_item_{i}"`` when absent, so a + dataset that never set an ``id`` still produces stable, unique + ``test_case_id`` values. +- ``metrics -> GraderResult`` (one grader result per metric, ``type="custom"``, + ``grader_id`` = the metric name), ``answer -> actual_output``, + ``metadata["latency_ms"] -> duration_ms`` (set by + ``Pipeline._evaluate_item``/``_generate``), and run-level metadata + (``EvaluationReport.metadata``, ``EvaluationReport.summary``) map onto the + ``ResultSet``'s own top-level fields (``metadata``, ``summary``). +- **Pipeline failures.** ``Pipeline._evaluate_item``'s exception boundary + does two things on a retrieval/generation/metric failure: it appends a + dict to ``PipelineResult.errors``, *and* it returns a zeroed + ``EvaluationResult`` (flagged via ``metadata["failed"] = True``) that + lands in ``PipelineResult.results`` like every other item. Both + representations describe the exact same failure. This adapter treats the + ``EvaluationResult`` in ``results`` as the single source of truth for + failed items and does not additionally walk ``PipelineResult.errors`` -- + earlier versions of this module did, and double-counted every failure (one + Result from the zeroed ``EvaluationResult``, a second synthetic one from + the matching ``errors`` entry), corrupting ``summary.total``/``pass_rate`` + silently. Sourcing failures from ``results`` alone also sidesteps a real + ordering hazard: ``PipelineResult.results`` preserves dataset order + (``Pipeline.execute`` awaits/gathers coroutines in item order), but + ``PipelineResult.errors`` is appended to from inside each item's own + coroutine, so under the parallel executor its order reflects completion + time, not dataset position -- there is no safe way to zip + ``errors[i]`` against ``items[i]``. + +This adapter is a standalone, directly-importable :class:`ReportGenerator` +(the same ABC every other format in this package implements -- see +``reports/base.py``) rather than one wired into the CLI's ``--output`` flag +or ``config.models.OutputFormat``. That keeps this first version scoped to +exactly what was asked for -- ``EvalPortReport().generate_to_file(report, +"result_set.json")`` after a normal ``oaeval run`` -- without also having to +extend the pydantic ``Config`` tree and the CLI's format registry in the same +change; wiring it into ``--output evalport`` is a natural, separately-scoped +follow-up once this conversion itself has been reviewed against real +``oaeval run`` output. +""" + +from __future__ import annotations + +import json +import logging +from datetime import UTC, datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from openagent_eval.reports.base import ReportGenerator + +if TYPE_CHECKING: + from openagent_eval.core.engine import EvaluationReport + from openagent_eval.core.pipeline import EvaluationResult + +logger = logging.getLogger(__name__) + +try: + from openeval import OPENEVAL_VERSION +except ImportError: # pragma: no cover - evalport-sdk is an optional extra + OPENEVAL_VERSION = "1.0.0" + logger.warning( + "evalport-sdk is not installed; EvalPortReport is falling back to a " + "bundled OPENEVAL_VERSION=%r, which may be stale. Install the " + "'evalport' extra (pip install openagent-eval[evalport]) to pick up " + "the SDK's real current spec version.", + OPENEVAL_VERSION, + ) + +__all__ = ["EvalPortReport", "evaluation_report_to_result_set"] + +DEFAULT_PASS_THRESHOLD = 0.5 + +# Keys on EvaluationResult.metadata that are either surfaced through a +# dedicated Result field elsewhere (id -> test_case_id, latency_ms -> +# duration_ms) or are Pipeline's own failure bookkeeping (failed, error, +# error_type -> the Result.error object) -- never re-exported verbatim into +# metadata.openagent_eval. +_METADATA_EXCLUDED_KEYS = ("id", "latency_ms", "failed", "error", "error_type") + + +def _now_iso() -> str: + """Current UTC time as a Zulu-suffixed ISO-8601 string. + + Deliberately seconds-precision, no fractional component -- this matches + the EvalPort SDK's own convention for ``started_at``/``completed_at`` + string fields. This is intentionally not the same convention + ``reports/json_report.py`` uses for its own ``metadata.timestamp`` + (``datetime.now(UTC).isoformat()``, which keeps microseconds and a + ``+00:00`` offset instead of ``Z``): these are two different fields in + two different schemas, not a value that needs to round-trip between them. + """ + return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _clamp_unit(score: float) -> float: + """Defensively clamp a score into EvalPort's required [0, 1] range. + + ``MetricResult.__post_init__`` already enforces ``0.0 <= score <= 1.0`` + for every built-in metric, so this is a belt-and-suspenders guard against + a third-party custom metric that does not go through ``MetricResult`` and + might otherwise produce a ``ResultSet`` that fails + ``openeval.validate.validate_result_set``. + """ + return max(0.0, min(1.0, float(score))) + + +def _validate_threshold(label: str, value: float) -> None: + """Raise ``ValueError`` if a pass threshold is outside EvalPort's [0, 1] score range. + + An out-of-range threshold fails silently otherwise: > 1.0 makes every + result fail (no score can ever meet it), < 0.0 makes every result pass + (every score already meets it) -- both are almost certainly a caller + mistake (e.g. passing a percentage like ``70`` instead of ``0.7``), not a + deliberate choice, so this is rejected outright rather than silently + misbehaving. + """ + if not 0.0 <= value <= 1.0: + raise ValueError(f"{label} must be in [0.0, 1.0], got {value!r}") + + +def _test_case_id(eval_result: EvaluationResult, run_id: str, index: int) -> str: + """Resolve a stable EvalPort ``test_case_id`` for one evaluated item. + + Prefers the dataset item's own ``id`` field, which the pipeline + preserves into ``EvaluationResult.metadata["id"]`` when the source + dataset item carries a ``metadata.id`` value (see + ``Pipeline._evaluate_item``) -- including for a failed item, since the + exception boundary now spreads the original item's ``metadata`` the same + way the success path does. Falls back to a positional id, stable within + one ``evaluation_report_to_result_set`` call because ``i`` is this item's + index in ``PipelineResult.results``, which preserves dataset order. + """ + item_id = eval_result.metadata.get("id") + if item_id is not None: + return str(item_id) + return f"{run_id}_item_{index}" + + +def _grader_results( + eval_result: EvaluationResult, + thresholds: dict[str, float], + default_threshold: float, +) -> list[dict[str, Any]]: + """Convert one ``EvaluationResult.metrics`` dict into EvalPort ``GraderResult`` entries.""" + grader_results: list[dict[str, Any]] = [] + for metric_name, raw_score in eval_result.metrics.items(): + if raw_score is None: + continue + score = _clamp_unit(raw_score) + threshold = thresholds.get(metric_name, default_threshold) + grader_results.append( + { + "grader_id": metric_name, + "type": "custom", + "score": score, + "passed": score >= threshold, + "reason": f"{metric_name}={score:.4f} (threshold={threshold})", + } + ) + return grader_results + + +def _result_metadata(eval_result: EvaluationResult) -> dict[str, Any]: + """Everything OpenAgent Eval attaches to an item that EvalPort's ``Result`` + schema itself doesn't have a dedicated field for -- the original + question, ground-truth reference, retrieved contexts, token usage, + per-item metric errors, and any caller-supplied dataset item metadata -- + is preserved here rather than silently dropped. + + Notably, ``Result`` has no ``input``/``expected_output`` fields of its + own (only ``test_case_id``, ``passed``, ``grader_results``, + ``actual_output``, ``duration_ms``, ``completed_at``, ``attempt``, + ``error``, ``metadata`` -- see ``openeval.Result.__dataclass_fields__``), + so ``question``/``ground_truth``/``contexts`` are carried here rather + than as invented top-level keys the schema doesn't define. + """ + preserved = { + k: v + for k, v in eval_result.metadata.items() + if k not in _METADATA_EXCLUDED_KEYS + } + openagent_eval: dict[str, Any] = dict(preserved) + if eval_result.question: + openagent_eval["question"] = eval_result.question + if eval_result.ground_truth is not None: + openagent_eval["ground_truth"] = eval_result.ground_truth + if eval_result.contexts: + openagent_eval["contexts"] = eval_result.contexts + return {"openagent_eval": openagent_eval} if openagent_eval else {} + + +def _failed_result(eval_result: EvaluationResult, test_case_id: str) -> dict[str, Any]: + """Build a failed EvalPort ``Result`` for an ``EvaluationResult`` whose + ``metadata["failed"]`` is ``True`` -- i.e. an item whose retrieval, + generation, or metric step raised inside + ``Pipeline._evaluate_item``'s exception boundary. + + This is the only place a pipeline-level failure is represented in the + exported ``ResultSet`` (see the module docstring's "Pipeline failures" + note for why ``PipelineResult.errors`` is not also walked here). + """ + metadata = {**_result_metadata(eval_result), "openeval_derived_pass": True} + return { + "test_case_id": test_case_id, + "grader_results": [], + "passed": False, + "error": { + "message": eval_result.metadata.get("error") or "Unknown error", + "detail": eval_result.metadata.get("error_type") or "Unknown", + }, + "metadata": metadata, + } + + +def evaluation_report_to_result_set( + report: EvaluationReport, + *, + evalport_thresholds: dict[str, float] | None = None, + default_threshold: float = DEFAULT_PASS_THRESHOLD, + suite_id: str | None = None, + run_id: str | None = None, + started_at: str | None = None, + completed_at: str | None = None, +) -> dict[str, Any]: + """Convert a completed :class:`EvaluationReport` into an EvalPort ``ResultSet``. + + Args: + report: The ``EvaluationReport`` produced by ``Engine.run()`` (or + reconstructed via ``ReportManager.load_report`` / + ``ReportManager.reconstruct``). + evalport_thresholds: Optional ``metric name -> pass threshold`` + mapping. OpenAgent Eval's own metrics carry no native pass/fail + concept -- only a ``[0.0, 1.0]`` score -- so a threshold is + required to derive ``GraderResult.passed``. Any metric not + listed here falls back to ``default_threshold``. Every value + (here and in ``default_threshold``) must be in ``[0.0, 1.0]``. + default_threshold: Pass threshold applied to any metric not present + in ``evalport_thresholds``. Defaults to ``0.5``, a neutral + midpoint appropriate for bare ``[0, 1]`` scores with no + tool-native threshold of their own. Must be in ``[0.0, 1.0]``. + suite_id: The EvalPort ``ResultSet.suite_id``. OpenAgent Eval does + not track the id of an externally-authored EvalPort suite it ran + against (it loads datasets via ``config.dataset.path``, not + EvalPort suites), so this defaults to that dataset path, falling + back to ``"openagent_eval_run"`` if the config has none. + run_id: The EvalPort ``ResultSet.run_id``. Defaults to + ``report.metadata.get("run_id")``, falling back to a + timestamp-based id if the report carries none -- which, as of + this writing, is every real ``Engine.run()`` call: the engine + does not currently set ``metadata["run_id"]`` (or + ``metadata["title"]``, see ``suite_id``/the ``metadata.title`` + note below). Pass ``run_id`` explicitly if the caller has one. + started_at / completed_at: ISO-8601 timestamps for the ResultSet. + ``started_at`` is required by the EvalPort schema; both default + to the current time if omitted, since ``EvaluationReport`` does + not itself carry run-level start/end timestamps. + + Returns: + A dict matching EvalPort's ``ResultSet`` schema. Validate with + ``openeval.validate.validate_result_set()``. + + Raises: + ValueError: if ``default_threshold`` or any value in + ``evalport_thresholds`` is outside ``[0.0, 1.0]``, or if + ``report.result.results`` is empty -- EvalPort's schema requires + at least one ``Result`` per ``ResultSet``. + """ + _validate_threshold("default_threshold", default_threshold) + for metric_name, threshold in (evalport_thresholds or {}).items(): + _validate_threshold(f"evalport_thresholds[{metric_name!r}]", threshold) + + result = report.result + if not result.results: + raise ValueError( + "evaluation_report_to_result_set: report.result.results is empty " + "-- EvalPort's ResultSet schema requires at least one result." + ) + + thresholds = dict(evalport_thresholds or {}) + + if suite_id is None: + dataset_path = getattr(report.config.dataset, "path", None) + suite_id = str(dataset_path) if dataset_path else "openagent_eval_run" + + if run_id is None: + meta_run_id = report.metadata.get("run_id") + run_id = ( + str(meta_run_id) + if meta_run_id + else f"openagent_eval_{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}" + ) + + results: list[dict[str, Any]] = [] + for i, eval_result in enumerate(result.results): + test_case_id = _test_case_id(eval_result, run_id, i) + + if eval_result.metadata.get("failed"): + results.append(_failed_result(eval_result, test_case_id)) + continue + + grader_results = _grader_results(eval_result, thresholds, default_threshold) + passed = all(g["passed"] for g in grader_results) if grader_results else False + + entry: dict[str, Any] = { + "test_case_id": test_case_id, + "grader_results": grader_results, + "passed": passed, + "metadata": { + **_result_metadata(eval_result), + "openeval_derived_pass": True, + }, + } + + if eval_result.answer is not None: + entry["actual_output"] = eval_result.answer + + latency_ms = eval_result.metadata.get("latency_ms") + if latency_ms is not None: + try: + entry["duration_ms"] = max(0, round(latency_ms)) + except (TypeError, ValueError): + # A non-numeric latency_ms (e.g. injected via a dataset item's + # own metadata spread) shouldn't crash the whole export -- + # just omit duration_ms for this result. + logger.warning( + "Ignoring non-numeric metadata['latency_ms']=%r for %s", + latency_ms, + test_case_id, + ) + + results.append(entry) + + total = len(results) + passed_count = sum(1 for r in results if r["passed"]) + + metadata_block: dict[str, Any] = { + "engine": report.metadata.get("engine", "openagent-eval"), + "version": report.metadata.get("version"), + "config_summary": report.summary, + } + title = report.metadata.get("title") + if title is not None: + metadata_block["title"] = title + + result_set: dict[str, Any] = { + "version": OPENEVAL_VERSION, + "suite_id": suite_id, + "run_id": run_id, + "started_at": started_at or _now_iso(), + "results": results, + "summary": { + "total": total, + "passed": passed_count, + "failed": total - passed_count, + "pass_rate": (passed_count / total) if total else 0.0, + }, + "metadata": {"openagent_eval": metadata_block}, + } + result_set["completed_at"] = completed_at or _now_iso() + + return result_set + + +class EvalPortReport(ReportGenerator): + """Generate EvalPort-formatted (``ResultSet`` JSON) evaluation reports. + + Usage mirrors every other generator in this package:: + + from openagent_eval.reports.evalport import EvalPortReport + + report = EvalPortReport(evalport_thresholds={"faithfulness": 0.7}) + report.generate_to_file(evaluation_report, "result_set.json") + + The output validates against ``openeval.validate.validate_result_set()`` + and can be consumed by any EvalPort-speaking tool (openeval's own CLI, + other adapters in the ecosystem, or a suite-comparison dashboard). + """ + + def __init__( + self, + *, + evalport_thresholds: dict[str, float] | None = None, + default_threshold: float = DEFAULT_PASS_THRESHOLD, + suite_id: str | None = None, + run_id: str | None = None, + started_at: str | None = None, + completed_at: str | None = None, + indent: int = 2, + ) -> None: + """Initialize the EvalPort report generator. + + Args: + evalport_thresholds: Optional ``metric name -> pass threshold`` + mapping used to derive each ``GraderResult.passed``. See + :func:`evaluation_report_to_result_set` for the full + rationale. Every value must be in ``[0.0, 1.0]``. + default_threshold: Pass threshold for any metric not listed in + ``evalport_thresholds``. Defaults to ``0.5``. Must be in + ``[0.0, 1.0]``. + suite_id: Optional override for the ResultSet's ``suite_id``. + Defaults to the run's dataset path. + run_id: Optional override for the ResultSet's ``run_id``. + Defaults to ``report.metadata["run_id"]`` or a generated, + timestamp-based id. + started_at: Optional override for the ResultSet's ``started_at``. + Defaults to the current time when the report is generated, + which is only an approximation of the real evaluation start + -- pass the actual run-start timestamp here when it's known. + completed_at: Optional override for the ResultSet's + ``completed_at``, with the same caveat as ``started_at``. + indent: JSON indentation level. ``0`` (or any negative value) + produces compact, single-line output, matching + ``JSONReport``'s own convention. + """ + self.evalport_thresholds = evalport_thresholds + self.default_threshold = default_threshold + self.suite_id = suite_id + self.run_id = run_id + self.started_at = started_at + self.completed_at = completed_at + self.indent = indent + + def to_result_set(self, report: EvaluationReport) -> dict[str, Any]: + """Convert ``report`` into an EvalPort ``ResultSet`` dict (unserialized).""" + return evaluation_report_to_result_set( + report, + evalport_thresholds=self.evalport_thresholds, + default_threshold=self.default_threshold, + suite_id=self.suite_id, + run_id=self.run_id, + started_at=self.started_at, + completed_at=self.completed_at, + ) + + def generate(self, report: EvaluationReport) -> str: + """Generate an EvalPort ``ResultSet`` as a JSON string. + + Args: + report: EvaluationReport containing config, results, and summary. + + Returns: + JSON-formatted ``ResultSet`` string. + """ + return json.dumps( + self.to_result_set(report), + indent=self.indent if self.indent > 0 else None, + ensure_ascii=False, + ) + + def generate_to_file( + self, report: EvaluationReport, output_path: Path | str + ) -> Path: + """Generate an EvalPort ``ResultSet`` and write it to a JSON file. + + Args: + report: EvaluationReport containing config, results, and summary. + output_path: Path to write the report file. A missing suffix is + treated as a directory (matching ``JSONReport``'s own + convention) and gets ``result_set.json`` appended; any + non-``.json`` suffix is replaced. + + Returns: + Path to the written file. + """ + path = Path(output_path) + if path.suffix == "": + path = path / "result_set.json" + elif path.suffix.lower() != ".json": + path = path.with_suffix(".json") + path = self._prepare_output_file(path) + content = self.generate(report) + path.write_text(content, encoding="utf-8") + return path diff --git a/pyproject.toml b/pyproject.toml index cabb30a..33afa71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,13 @@ datasets = [ pdf = [ "pypdf>=4.0.0", ] +# EvalPort (https://github.com/adhabnr-ux/evalport) ResultSet export via +# openagent_eval.reports.evalport.EvalPortReport. Not required to import that +# module (it degrades to a bundled OPENEVAL_VERSION fallback without it), but +# is required to validate its output against openeval.validate.validate_result_set. +evalport = [ + "evalport-sdk>=1.0.0", +] # --- LLM provider extras ------------------------------------------------ # These SDKs are imported unconditionally by their respective provider # adapters (openagent_eval/providers/llm/*), so they must be installed to @@ -88,7 +95,7 @@ pgvector = ["psycopg[binary]>=3.1.0", "pgvector>=0.2.0"] elasticsearch = ["elasticsearch>=8.0.0"] bm25 = ["rank-bm25>=0.2.0"] all = [ - "openagent-eval[dev,evaluation,corpus,nli,datasets,pdf,providers,qdrant,pinecone,weaviate,faiss,pgvector,elasticsearch,bm25]", + "openagent-eval[dev,evaluation,corpus,nli,datasets,pdf,providers,qdrant,pinecone,weaviate,faiss,pgvector,elasticsearch,bm25,evalport]", ] [project.scripts] diff --git a/tests/unit/test_reports/test_evalport_report.py b/tests/unit/test_reports/test_evalport_report.py new file mode 100644 index 0000000..8d67192 --- /dev/null +++ b/tests/unit/test_reports/test_evalport_report.py @@ -0,0 +1,673 @@ +"""Tests for EvalPortReport generator. + +Every test here validates its produced ResultSet against the real +``openeval.validate.validate_result_set`` from evalport-sdk (not a mock or a +hand-rolled schema check) -- these tests are skipped, not faked, if +evalport-sdk is not installed (see ``pytest.importorskip`` below), since +validating against anything other than the actual EvalPort SDK's validator +would not prove the adapter's output is spec-conformant. + +Two families of fixtures are used: + +- The shared ``evaluation_report`` fixture from ``conftest.py`` (backed by + ``pipeline_result_with_data``): 3 successful ``EvaluationResult``s plus a + separate, non-overlapping ``PipelineResult.errors`` list. That shape is + shared with every other report generator's tests (``JSONReport`` etc., + which read ``result.errors`` directly and are unaffected by anything + below), so it is left untouched here -- this module only asserts what its + own exporter does with it, which is to walk ``result.results`` and ignore + ``result.errors`` entirely (see the module docstring on + ``openagent_eval.reports.evalport`` for why). +- The local ``realistic_evaluation_report`` fixture below, which instead + mirrors what ``Pipeline._evaluate_item`` actually produces on a failure: + the SAME failure recorded both as a zeroed, ``metadata["failed"] = True`` + ``EvaluationResult`` in ``results`` AND as a dict in ``errors``. This is + the shape that exposed the double-counting bug this module's exporter used + to have (every failure exported twice), so it is what the regression tests + for that fix are built against -- asserting against the old fixture would + not catch a regression, since that fixture was never shaped like real + pipeline output in the first place. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +import pytest + +from openagent_eval.core.engine import EvaluationReport +from openagent_eval.core.pipeline import EvaluationResult, PipelineResult +from openagent_eval.reports.evalport import ( + DEFAULT_PASS_THRESHOLD, + EvalPortReport, + evaluation_report_to_result_set, +) + +if TYPE_CHECKING: + from pathlib import Path + + from openagent_eval.config.models import Config + +openeval_validate = pytest.importorskip( + "openeval.validate", + reason="evalport-sdk not installed (pip install openagent-eval[evalport])", +) + + +def _assert_valid_result_set(result_set: dict[str, Any]) -> None: + """Validate a ResultSet dict against the real evalport-sdk validator.""" + validation = openeval_validate.validate_result_set(result_set) + assert validation.valid, ( + f"ResultSet failed EvalPort validation: {validation.errors}" + ) + + +@pytest.fixture +def realistic_pipeline_result() -> PipelineResult: + """A PipelineResult shaped exactly like real ``Pipeline.execute()`` output. + + 2 items succeed normally. 2 items fail inside + ``Pipeline._evaluate_item``'s exception boundary -- each failure is + represented TWICE in the real pipeline's own output, matching + ``pipeline.py``'s actual behavior (post-fix, which now also spreads the + original item's ``metadata`` into the failure, same as the success + path): + + - once as a zeroed ``EvaluationResult`` in ``results`` (``metrics`` all + ``0.0``, ``metadata={"failed": True, "error": ..., "error_type": ..., + **item_metadata}``), in dataset order; + - once as a plain dict in ``errors`` (``{"item": ..., "error": ..., + "error_type": ...}``), in whatever order the coroutines completed -- + here written in reverse of dataset order specifically to prove the + exporter does not (and safely cannot) rely on ``errors`` ordering. + """ + results = [ + EvaluationResult( + question="What is Python?", + answer="Python is a programming language.", + metrics={"faithfulness": 0.9}, + metadata={"id": "ok-1"}, + ), + EvaluationResult( + question="Failing question A", + answer="", + metrics={"faithfulness": 0.0}, + metadata={ + "failed": True, + "error": "Connection timeout", + "error_type": "ProviderConnectionError", + "id": "fail-a", + }, + ), + EvaluationResult( + question="What is RAG?", + answer="RAG combines retrieval and generation.", + metrics={"faithfulness": 0.85}, + metadata={"id": "ok-2"}, + ), + EvaluationResult( + question="Failing question B", + answer="", + metrics={"faithfulness": 0.0}, + # No dataset-provided id -- must fall back positionally to this + # item's own index within `results` (3), not the index it + # happens to occupy in the (differently ordered) `errors` list. + metadata={ + "failed": True, + "error": "Invalid response format", + "error_type": "ProviderExecutionError", + }, + ), + ] + errors = [ + # Reverse dataset order on purpose (see docstring above). + { + "item": {"question": "Failing question B"}, + "error": "Invalid response format", + "error_type": "ProviderExecutionError", + }, + { + "item": {"question": "Failing question A"}, + "error": "Connection timeout", + "error_type": "ProviderConnectionError", + }, + ] + return PipelineResult( + results=results, + summary={"total": 4, "errors": 2}, + errors=errors, + ) + + +@pytest.fixture +def realistic_evaluation_report( + sample_config: Config, + realistic_pipeline_result: PipelineResult, +) -> EvaluationReport: + """An EvaluationReport wrapping :func:`realistic_pipeline_result`.""" + return EvaluationReport( + config=sample_config, + result=realistic_pipeline_result, + summary={ + "total_items": 4, + "successful_evaluations": 2, + "failed_evaluations": 2, + }, + metadata={ + "version": "0.1.0", + "engine": "openagent-eval", + "title": "Realistic Report", + }, + ) + + +class TestEvaluationReportToResultSet: + """Tests for the standalone evaluation_report_to_result_set() function.""" + + def test_returns_valid_result_set( + self, evaluation_report: EvaluationReport + ) -> None: + """The produced dict validates against openeval.validate.validate_result_set.""" + result_set = evaluation_report_to_result_set(evaluation_report) + _assert_valid_result_set(result_set) + + def test_version_is_semver(self, evaluation_report: EvaluationReport) -> None: + """version must match the same SEMVER_RE the real validator checks it against.""" + from openeval import SEMVER_RE + + result_set = evaluation_report_to_result_set(evaluation_report) + assert isinstance(result_set["version"], str) + assert SEMVER_RE.match(result_set["version"]) + + def test_result_count_matches_results_only( + self, evaluation_report: EvaluationReport + ) -> None: + """results is sourced from PipelineResult.results alone. + + The shared `evaluation_report` fixture's `pipeline_result_with_data` + has 3 successful EvaluationResults and a separately-tracked, + non-overlapping `errors` list (a shape real pipeline output never + actually produces -- see this module's docstring). The exporter + does not consume `PipelineResult.errors` at all (see + `openagent_eval.reports.evalport`'s module docstring for why), so + only the 3 successes show up here. + """ + result_set = evaluation_report_to_result_set(evaluation_report) + assert len(result_set["results"]) == 3 + assert result_set["summary"]["total"] == 3 + + def test_test_case_id_reads_metadata_id( + self, evaluation_report: EvaluationReport + ) -> None: + """test_case_id prefers EvaluationResult.metadata['id'] when present.""" + result_set = evaluation_report_to_result_set(evaluation_report) + # pipeline_result_with_data fixture sets metadata={"id": 1/2/3} on its + # three successful EvaluationResults, in order. + ids = [r["test_case_id"] for r in result_set["results"][:3]] + assert ids == ["1", "2", "3"] + + def test_grader_results_one_per_metric( + self, evaluation_report: EvaluationReport + ) -> None: + """Each metric on an EvaluationResult becomes one GraderResult.""" + result_set = evaluation_report_to_result_set(evaluation_report) + first = result_set["results"][0] + grader_ids = {g["grader_id"] for g in first["grader_results"]} + assert grader_ids == {"precision", "recall", "faithfulness"} + for g in first["grader_results"]: + assert g["type"] == "custom" + assert 0.0 <= g["score"] <= 1.0 + assert isinstance(g["passed"], bool) + + def test_default_threshold_pass(self, evaluation_report: EvaluationReport) -> None: + """All fixture scores are >= 0.5, the default threshold -> every result passes.""" + result_set = evaluation_report_to_result_set(evaluation_report) + for r in result_set["results"][:3]: + assert r["passed"] is True + assert all(g["passed"] for g in r["grader_results"]) + + def test_custom_threshold_can_fail_a_metric( + self, evaluation_report: EvaluationReport + ) -> None: + """A stricter evalport_thresholds entry can flip a grader (and the result) to failing.""" + # Item 1's precision is 0.95; item 3's precision is 0.78. + result_set = evaluation_report_to_result_set( + evaluation_report, evalport_thresholds={"precision": 0.99} + ) + precisions = [ + next(g for g in r["grader_results"] if g["grader_id"] == "precision") + for r in result_set["results"][:3] + ] + assert all(g["passed"] is False for g in precisions) + # A failing grader_result makes the whole Result fail, per EvalPort's + # "every grader result must pass" convention. + assert all(r["passed"] is False for r in result_set["results"][:3]) + + def test_derived_pass_is_flagged(self, evaluation_report: EvaluationReport) -> None: + """Every result's metadata.openeval_derived_pass is True, since OpenAgent Eval + has no native pass/fail -- this is always a threshold-derived judgment.""" + result_set = evaluation_report_to_result_set(evaluation_report) + for r in result_set["results"]: + assert r["metadata"]["openeval_derived_pass"] is True + + def test_actual_output_maps_from_answer( + self, evaluation_report: EvaluationReport + ) -> None: + result_set = evaluation_report_to_result_set(evaluation_report) + assert ( + result_set["results"][0]["actual_output"] + == "Python is a programming language." + ) + + def test_question_and_ground_truth_preserved_in_metadata( + self, evaluation_report: EvaluationReport + ) -> None: + """question/ground_truth/contexts have no Result-schema field of their own + (Result has no `input`/`expected_output`), so they're preserved under + metadata.openagent_eval instead of being dropped or invented as new + top-level keys the schema doesn't define.""" + result_set = evaluation_report_to_result_set(evaluation_report) + first_meta = result_set["results"][0]["metadata"]["openagent_eval"] + assert first_meta["question"] == "What is Python?" + assert ( + first_meta["ground_truth"] == "Python is a high-level programming language." + ) + assert first_meta["contexts"] == [ + "Python is a programming language created by Guido van Rossum." + ] + + def test_duration_ms_present_when_latency_available( + self, sample_config: Config + ) -> None: + """metadata['latency_ms'] (set by Pipeline._evaluate_item) maps to duration_ms, + rounded to the nearest non-negative integer millisecond.""" + result = PipelineResult( + results=[ + EvaluationResult( + question="q", + answer="a", + metrics={"faithfulness": 0.9}, + metadata={"id": "x1", "latency_ms": 123.6}, + ) + ], + ) + report = EvaluationReport(config=sample_config, result=result) + result_set = evaluation_report_to_result_set(report) + assert result_set["results"][0]["duration_ms"] == 124 + _assert_valid_result_set(result_set) + + def test_duration_ms_absent_when_latency_missing( + self, evaluation_report: EvaluationReport + ) -> None: + """The fixture's EvaluationResults carry no latency_ms -> no duration_ms key.""" + result_set = evaluation_report_to_result_set(evaluation_report) + assert "duration_ms" not in result_set["results"][0] + + def test_non_numeric_latency_ms_does_not_crash(self, sample_config: Config) -> None: + """A non-numeric latency_ms (e.g. injected via a dataset item's own + metadata spread) is ignored rather than raising a TypeError out of + round().""" + result = PipelineResult( + results=[ + EvaluationResult( + question="q", + answer="a", + metrics={"faithfulness": 0.9}, + metadata={"id": "x1", "latency_ms": "not-a-number"}, + ) + ], + ) + report = EvaluationReport(config=sample_config, result=result) + result_set = evaluation_report_to_result_set(report) + assert "duration_ms" not in result_set["results"][0] + _assert_valid_result_set(result_set) + + def test_score_out_of_range_is_clamped(self, sample_config: Config) -> None: + """A misbehaving third-party metric that doesn't go through MetricResult's + own [0,1] enforcement is still defensively clamped before it can produce + an invalid ResultSet.""" + result = PipelineResult( + results=[ + EvaluationResult( + question="q", + answer="a", + metrics={"weird_metric": 1.5}, + metadata={"id": "x1"}, + ) + ], + ) + report = EvaluationReport(config=sample_config, result=result) + result_set = evaluation_report_to_result_set(report) + assert result_set["results"][0]["grader_results"][0]["score"] == 1.0 + _assert_valid_result_set(result_set) + + def test_none_scores_are_skipped(self, sample_config: Config) -> None: + result = PipelineResult( + results=[ + EvaluationResult( + question="q", + answer="a", + metrics={"faithfulness": None}, # type: ignore[dict-item] + metadata={"id": "x1"}, + ) + ], + ) + report = EvaluationReport(config=sample_config, result=result) + result_set = evaluation_report_to_result_set(report) + assert result_set["results"][0]["grader_results"] == [] + # No grader results -> cannot confirm a pass. + assert result_set["results"][0]["passed"] is False + + def test_empty_report_raises( + self, evaluation_report_empty: EvaluationReport + ) -> None: + with pytest.raises(ValueError, match="empty"): + evaluation_report_to_result_set(evaluation_report_empty) + + def test_suite_id_defaults_to_dataset_path( + self, evaluation_report: EvaluationReport + ) -> None: + result_set = evaluation_report_to_result_set(evaluation_report) + assert result_set["suite_id"] == "tests/sample_data/test_dataset.json" + + def test_suite_id_override(self, evaluation_report: EvaluationReport) -> None: + result_set = evaluation_report_to_result_set( + evaluation_report, suite_id="custom_suite" + ) + assert result_set["suite_id"] == "custom_suite" + + def test_run_id_override(self, evaluation_report: EvaluationReport) -> None: + result_set = evaluation_report_to_result_set(evaluation_report, run_id="run-42") + assert result_set["run_id"] == "run-42" + + def test_started_at_completed_at_override( + self, evaluation_report: EvaluationReport + ) -> None: + result_set = evaluation_report_to_result_set( + evaluation_report, + started_at="2026-01-01T00:00:00Z", + completed_at="2026-01-01T00:05:00Z", + ) + assert result_set["started_at"] == "2026-01-01T00:00:00Z" + assert result_set["completed_at"] == "2026-01-01T00:05:00Z" + _assert_valid_result_set(result_set) + + def test_summary_pass_rate(self, evaluation_report: EvaluationReport) -> None: + result_set = evaluation_report_to_result_set(evaluation_report) + summary = result_set["summary"] + assert summary["total"] == 3 + assert ( + summary["passed"] == 3 + ) # all 3 successful results pass at the default threshold + assert summary["failed"] == 0 + assert summary["pass_rate"] == pytest.approx(1.0) + + def test_openagent_eval_metadata_present( + self, evaluation_report: EvaluationReport + ) -> None: + result_set = evaluation_report_to_result_set(evaluation_report) + assert result_set["metadata"]["openagent_eval"]["engine"] == "openagent-eval" + assert result_set["metadata"]["openagent_eval"]["title"] == "Test Report" + assert result_set["metadata"]["openagent_eval"]["config_summary"] == ( + evaluation_report.summary + ) + + def test_title_omitted_when_report_has_no_title( + self, sample_config: Config + ) -> None: + """report.metadata.get('title') is None for every real Engine.run() call + today (Engine never sets a 'title' key) -- the exported metadata should + omit the key entirely rather than ship a literal `"title": null`.""" + result = PipelineResult( + results=[ + EvaluationResult( + question="q", + answer="a", + metrics={"faithfulness": 0.9}, + metadata={"id": "x1"}, + ) + ], + ) + report = EvaluationReport( + config=sample_config, + result=result, + metadata={"version": "0.1.0", "engine": "openagent-eval"}, # no "title" + ) + result_set = evaluation_report_to_result_set(report) + assert "title" not in result_set["metadata"]["openagent_eval"] + _assert_valid_result_set(result_set) + + def test_default_pass_threshold_constant(self) -> None: + assert DEFAULT_PASS_THRESHOLD == 0.5 + + def test_default_threshold_above_one_raises( + self, evaluation_report: EvaluationReport + ) -> None: + with pytest.raises(ValueError, match=r"default_threshold.*\[0\.0, 1\.0\]"): + evaluation_report_to_result_set(evaluation_report, default_threshold=1.5) + + def test_default_threshold_below_zero_raises( + self, evaluation_report: EvaluationReport + ) -> None: + with pytest.raises(ValueError, match=r"default_threshold.*\[0\.0, 1\.0\]"): + evaluation_report_to_result_set(evaluation_report, default_threshold=-0.1) + + def test_evalport_thresholds_entry_out_of_range_raises( + self, evaluation_report: EvaluationReport + ) -> None: + with pytest.raises(ValueError, match=r"evalport_thresholds\['precision'\]"): + evaluation_report_to_result_set( + evaluation_report, evalport_thresholds={"precision": 1.2} + ) + + +class TestFailedItemRepresentation: + """Regression coverage for the double-counting bug (#369 review, point 1). + + ``Pipeline._evaluate_item``'s exception boundary records a failure twice + -- once as a zeroed ``EvaluationResult`` in ``results``, once as a plain + dict in ``errors`` -- so every test here uses the + ``realistic_evaluation_report``/``realistic_pipeline_result`` fixtures, + which reproduce exactly that double-appearance shape, rather than the + shared ``evaluation_report`` fixture (whose ``results``/``errors`` never + overlap and so cannot catch a double-counting regression). + """ + + def test_failed_items_are_not_double_counted( + self, realistic_evaluation_report: EvaluationReport + ) -> None: + """4 PipelineResult.results entries (2 ok + 2 failed) -> exactly 4 + Results out, not 6 -- each of the 2 failures must appear once, not + once from `results` and again from `errors`.""" + result_set = evaluation_report_to_result_set(realistic_evaluation_report) + assert len(result_set["results"]) == 4 + assert result_set["summary"]["total"] == 4 + assert result_set["summary"]["passed"] == 2 + assert result_set["summary"]["failed"] == 2 + _assert_valid_result_set(result_set) + + def test_failed_result_shape( + self, realistic_evaluation_report: EvaluationReport + ) -> None: + result_set = evaluation_report_to_result_set(realistic_evaluation_report) + failed = [r for r in result_set["results"] if r["passed"] is False] + assert len(failed) == 2 + for r in failed: + assert r["grader_results"] == [] + assert r["metadata"]["openeval_derived_pass"] is True + + def test_failed_result_error_sourced_from_evaluation_result( + self, realistic_evaluation_report: EvaluationReport + ) -> None: + """error.message/error.detail come from the EvaluationResult's own + metadata (the source of truth used here), not from a separate walk + over PipelineResult.errors.""" + result_set = evaluation_report_to_result_set(realistic_evaluation_report) + by_id = {r["test_case_id"]: r for r in result_set["results"]} + assert by_id["fail-a"]["error"] == { + "message": "Connection timeout", + "detail": "ProviderConnectionError", + } + + def test_failed_result_test_case_id_uses_preserved_item_id( + self, realistic_evaluation_report: EvaluationReport + ) -> None: + """The dataset item's own id, preserved by Pipeline._evaluate_item's + exception handler into the failed EvaluationResult's metadata, is + used exactly like a successful item's id would be.""" + result_set = evaluation_report_to_result_set(realistic_evaluation_report) + ids = [r["test_case_id"] for r in result_set["results"]] + assert "fail-a" in ids + + def test_failed_result_test_case_id_positional_fallback( + self, realistic_evaluation_report: EvaluationReport + ) -> None: + """The second failure carries no id, so it falls back to this item's + own positional index within `results` (3) -- not any index derived + from `errors`, whose order does not track dataset position under the + parallel executor (see the fixture's docstring).""" + result_set = evaluation_report_to_result_set( + realistic_evaluation_report, run_id="myrun" + ) + ids = [r["test_case_id"] for r in result_set["results"]] + assert "myrun_item_3" in ids + + def test_failed_result_metadata_excludes_bookkeeping_keys( + self, realistic_evaluation_report: EvaluationReport + ) -> None: + """`failed`/`error`/`error_type` are Pipeline's own bookkeeping keys, + already surfaced via the Result-level `error` object -- they must not + also leak into metadata.openagent_eval verbatim.""" + result_set = evaluation_report_to_result_set(realistic_evaluation_report) + by_id = {r["test_case_id"]: r for r in result_set["results"]} + failed_meta = by_id["fail-a"]["metadata"].get("openagent_eval", {}) + assert "failed" not in failed_meta + assert "error" not in failed_meta + assert "error_type" not in failed_meta + + +class TestEvalPortReport: + """Tests for the EvalPortReport ReportGenerator subclass.""" + + def test_generate_returns_valid_json( + self, evaluation_report: EvaluationReport + ) -> None: + report = EvalPortReport() + result = report.generate(evaluation_report) + assert isinstance(result, str) + data = json.loads(result) + _assert_valid_result_set(data) + + def test_generate_respects_constructor_thresholds( + self, evaluation_report: EvaluationReport + ) -> None: + report = EvalPortReport(evalport_thresholds={"precision": 0.99}) + data = json.loads(report.generate(evaluation_report)) + assert data["results"][0]["passed"] is False + + def test_constructor_threshold_out_of_range_raises_on_generate( + self, evaluation_report: EvaluationReport + ) -> None: + report = EvalPortReport(default_threshold=2.0) + with pytest.raises(ValueError, match=r"\[0\.0, 1\.0\]"): + report.generate(evaluation_report) + + def test_started_at_completed_at_passthrough( + self, evaluation_report: EvaluationReport + ) -> None: + report = EvalPortReport( + started_at="2026-02-02T00:00:00Z", + completed_at="2026-02-02T00:10:00Z", + ) + data = json.loads(report.generate(evaluation_report)) + assert data["started_at"] == "2026-02-02T00:00:00Z" + assert data["completed_at"] == "2026-02-02T00:10:00Z" + + def test_to_result_set_matches_generate( + self, evaluation_report: EvaluationReport + ) -> None: + report = EvalPortReport(run_id="fixed-run") + as_dict = report.to_result_set(evaluation_report) + as_json = json.loads(report.generate(evaluation_report)) + assert as_dict == as_json + + def test_generate_to_file( + self, evaluation_report: EvaluationReport, tmp_path: Path + ) -> None: + report = EvalPortReport() + output_path = tmp_path / "result_set.json" + result_path = report.generate_to_file(evaluation_report, output_path) + + assert result_path == output_path + assert result_path.exists() + data = json.loads(result_path.read_text(encoding="utf-8")) + _assert_valid_result_set(data) + + def test_generate_to_file_adds_extension_for_bare_path( + self, evaluation_report: EvaluationReport, tmp_path: Path + ) -> None: + """A suffix-less path is treated as a directory, matching JSONReport's + own generate_to_file() convention.""" + report = EvalPortReport() + output_path = tmp_path / "out" + result_path = report.generate_to_file(evaluation_report, output_path) + + assert result_path == output_path / "result_set.json" + assert result_path.exists() + + def test_generate_to_file_replaces_non_json_extension( + self, evaluation_report: EvaluationReport, tmp_path: Path + ) -> None: + report = EvalPortReport() + output_path = tmp_path / "result_set.txt" + result_path = report.generate_to_file(evaluation_report, output_path) + + assert result_path == tmp_path / "result_set.json" + assert result_path.exists() + + def test_generate_to_file_creates_parent_directories( + self, evaluation_report: EvaluationReport, tmp_path: Path + ) -> None: + report = EvalPortReport() + output_path = tmp_path / "nested" / "dir" / "result_set.json" + result_path = report.generate_to_file(evaluation_report, output_path) + + assert result_path.exists() + assert result_path.parent.exists() + + def test_generate_empty_report_raises( + self, evaluation_report_empty: EvaluationReport + ) -> None: + report = EvalPortReport() + with pytest.raises(ValueError, match="empty"): + report.generate(evaluation_report_empty) + + def test_compact_indent(self, evaluation_report: EvaluationReport) -> None: + report = EvalPortReport(indent=0) + result = report.generate(evaluation_report) + assert "\n" not in result + data = json.loads(result) + _assert_valid_result_set(data) + + def test_negative_indent_is_also_compact( + self, evaluation_report: EvaluationReport + ) -> None: + """Matches JSONReport's own convention: any indent <= 0 is compact.""" + report = EvalPortReport(indent=-1) + result = report.generate(evaluation_report) + assert "\n" not in result + data = json.loads(result) + _assert_valid_result_set(data) + + def test_is_a_report_generator(self) -> None: + from openagent_eval.reports.base import ReportGenerator + + assert issubclass(EvalPortReport, ReportGenerator) + + def test_importable_from_reports_package(self) -> None: + """EvalPortReport is exported from openagent_eval.reports, matching + every other built-in generator (TerminalReport, JSONReport, ...).""" + from openagent_eval.reports import EvalPortReport as PackageExport + + assert PackageExport is EvalPortReport