agentx-python 0.6.39__tar.gz → 0.8.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of agentx-python might be problematic. Click here for more details.
- {agentx_python-0.6.39 → agentx_python-0.8.2}/PKG-INFO +1 -1
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/evaluations/client.py +58 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/evaluations/models.py +61 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/evaluations/runner.py +14 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/integrations/langchain.py +4 -0
- agentx_python-0.8.2/agentx/testing.py +165 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/tracing/tracer.py +23 -0
- agentx_python-0.8.2/agentx/version.py +1 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx_python.egg-info/PKG-INFO +1 -1
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx_python.egg-info/SOURCES.txt +4 -1
- agentx_python-0.8.2/tests/test_pairwise.py +119 -0
- agentx_python-0.8.2/tests/test_testing.py +49 -0
- agentx_python-0.6.39/agentx/version.py +0 -1
- {agentx_python-0.6.39 → agentx_python-0.8.2}/LICENSE +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/README.md +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/__init__.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/agentx.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/cli.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/evaluations/__init__.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/evaluations/_term.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/evaluations/adapters/__init__.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/evaluations/adapters/http_endpoint.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/evaluations/adapters/precomputed.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/evaluations/adapters/raw.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/evaluations/datasets.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/evaluations/evaluation_settings.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/evaluations/prompts.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/evaluations/reporting.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/evaluations/results.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/evaluations/tool_schemas.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/evaluations/tracing.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/exceptions.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/export.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/feedback.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/integrations/__init__.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/integrations/_traced_call.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/integrations/anthropic.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/integrations/autogen.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/integrations/crewai.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/integrations/databricks.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/integrations/google_adk.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/integrations/google_genai.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/integrations/litellm.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/integrations/llamaindex.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/integrations/moveworks.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/integrations/openai.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/integrations/openai_agents.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/monitor/__init__.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/monitor/agents.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/monitor/client.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/monitor/judge_scorers.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/monitor/models.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/monitor/online_evaluators.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/monitor/patterns.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/monitor/profile.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/monitor/scorers.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/monitor/sessions.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/monitor/signals.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/outcomes.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/projects.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/py.typed +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/resources/__init__.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/resources/agent.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/resources/conversation.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/resources/workforce.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/traces.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/tracing/__init__.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/tracing/ci_types.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/tracing/ingest_client.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx/util.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx_python.egg-info/dependency_links.txt +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx_python.egg-info/entry_points.txt +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx_python.egg-info/not-zip-safe +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx_python.egg-info/requires.txt +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/agentx_python.egg-info/top_level.txt +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/setup.cfg +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/setup.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/tests/test_deep_dive_fixes.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/tests/test_docs_match_sdk.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/tests/test_integration.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/tests/test_integrations.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/tests/test_judge_scorers.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/tests/test_selfhost_analysis_fallback.py +0 -0
- {agentx_python-0.6.39 → agentx_python-0.8.2}/tests/test_span_tree.py +0 -0
|
@@ -17,6 +17,7 @@ from agentx.evaluations.models import (
|
|
|
17
17
|
EvaluationSettings,
|
|
18
18
|
EvaluationSubject,
|
|
19
19
|
ModelInfo,
|
|
20
|
+
PairwiseComparison,
|
|
20
21
|
Prompt,
|
|
21
22
|
Report,
|
|
22
23
|
)
|
|
@@ -541,6 +542,63 @@ class EvaluationsClient:
|
|
|
541
542
|
"POST", f"/evaluate/prompts/{prompt_id}/versions", base=self._api_root, json=payload
|
|
542
543
|
)
|
|
543
544
|
|
|
545
|
+
# ------------------------------------------------------------------
|
|
546
|
+
# Head-to-head (pairwise) judging. Rides the /evaluate dialect via _api_root(), same
|
|
547
|
+
# precedent as get_report and the prompt loop above.
|
|
548
|
+
# ------------------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
def compare_pairwise(
|
|
551
|
+
self,
|
|
552
|
+
run_a_id: str,
|
|
553
|
+
run_b_id: str,
|
|
554
|
+
*,
|
|
555
|
+
criteria: Optional[str] = None,
|
|
556
|
+
judge_model: Optional[str] = None,
|
|
557
|
+
both_orders: bool = False,
|
|
558
|
+
) -> PairwiseComparison:
|
|
559
|
+
"""Ask a judge which of two runs answered each question better.
|
|
560
|
+
|
|
561
|
+
Both runs must be of the same dataset. Absolute ratings answer "is this above the bar";
|
|
562
|
+
this answers "did the change help", which is the question a diff between two runs is
|
|
563
|
+
actually asking. The judge sees the two answers as "Answer 1"/"Answer 2" with the order
|
|
564
|
+
alternating case by case, so it never learns which run is the candidate.
|
|
565
|
+
|
|
566
|
+
``both_orders=True`` judges every pair twice with the sides swapped. It doubles the judge
|
|
567
|
+
cost and is the only real defense against position bias: a pair whose winner reverses is
|
|
568
|
+
recorded as a tie, and the batch reports its ``flip_rate``.
|
|
569
|
+
|
|
570
|
+
``criteria`` and ``judge_model`` default to the dataset's own evaluation criteria and
|
|
571
|
+
judge model, so a head-to-head grades on the same terms a normal run of it does.
|
|
572
|
+
"""
|
|
573
|
+
payload: Dict[str, Any] = {"runAId": run_a_id, "runBId": run_b_id}
|
|
574
|
+
if criteria:
|
|
575
|
+
payload["criteria"] = criteria
|
|
576
|
+
if judge_model:
|
|
577
|
+
payload["judgeModel"] = judge_model
|
|
578
|
+
if both_orders:
|
|
579
|
+
payload["bothOrders"] = True
|
|
580
|
+
response = self._request("POST", "/evaluate/runs/pairwise", json=payload, base=self._api_root)
|
|
581
|
+
return PairwiseComparison(**response["comparison"])
|
|
582
|
+
|
|
583
|
+
def get_pairwise(self, batch_id: str) -> PairwiseComparison:
|
|
584
|
+
"""Read back a stored head-to-head by its batch id."""
|
|
585
|
+
response = self._request("GET", f"/evaluate/runs/pairwise/{batch_id}", base=self._api_root)
|
|
586
|
+
return PairwiseComparison(**response["comparison"])
|
|
587
|
+
|
|
588
|
+
def list_pairwise(
|
|
589
|
+
self, *, run_a_id: Optional[str] = None, run_b_id: Optional[str] = None
|
|
590
|
+
) -> List[Dict[str, Any]]:
|
|
591
|
+
"""Summaries of past head-to-heads, newest first, optionally narrowed to one run."""
|
|
592
|
+
params: Dict[str, Any] = {}
|
|
593
|
+
if run_a_id:
|
|
594
|
+
params["runAId"] = run_a_id
|
|
595
|
+
if run_b_id:
|
|
596
|
+
params["runBId"] = run_b_id
|
|
597
|
+
response = self._request(
|
|
598
|
+
"GET", "/evaluate/runs/pairwise", params=params or None, base=self._api_root
|
|
599
|
+
)
|
|
600
|
+
return response.get("comparisons", [])
|
|
601
|
+
|
|
544
602
|
# ------------------------------------------------------------------
|
|
545
603
|
# Tool schema registry (same version-scoped propose/publish loop as prompts)
|
|
546
604
|
# ------------------------------------------------------------------
|
|
@@ -742,3 +742,64 @@ class AnalysisStatus(BaseModel):
|
|
|
742
742
|
@property
|
|
743
743
|
def is_terminal(self) -> bool:
|
|
744
744
|
return self.status in ("completed", "partially_failed", "failed")
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
class PairwiseSummary(BaseModel):
|
|
748
|
+
"""Batch-level verdict of a head-to-head comparison (``compare_pairwise``).
|
|
749
|
+
|
|
750
|
+
``winner`` is "a", "b", or "tie" - a dead heat is reported as a tie rather than broken
|
|
751
|
+
arbitrarily. ``flip_rate`` is only populated for a ``both_orders=True`` comparison: it is the
|
|
752
|
+
share of cases whose winner reversed when the two answers were swapped, which is position
|
|
753
|
+
bias rather than quality. A high flip rate means the batch is inconclusive, so it is reported
|
|
754
|
+
instead of being folded away."""
|
|
755
|
+
|
|
756
|
+
total: int = 0
|
|
757
|
+
a_wins: int = Field(default=0, alias="aWins")
|
|
758
|
+
b_wins: int = Field(default=0, alias="bWins")
|
|
759
|
+
ties: int = 0
|
|
760
|
+
winner: str = "tie"
|
|
761
|
+
flip_rate: Optional[float] = Field(default=None, alias="flipRate")
|
|
762
|
+
|
|
763
|
+
class Config:
|
|
764
|
+
populate_by_name = True
|
|
765
|
+
extra = "ignore"
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
class PairwiseCase(BaseModel):
|
|
769
|
+
"""One question's verdict. ``presented_first`` records which run's answer the judge read
|
|
770
|
+
first, because that is the confound pairwise judging exists to control for."""
|
|
771
|
+
|
|
772
|
+
id: Optional[str] = Field(default=None, alias="_id")
|
|
773
|
+
question_index: Optional[int] = Field(default=None, alias="questionIndex")
|
|
774
|
+
query: Optional[str] = None
|
|
775
|
+
winner: str = "tie"
|
|
776
|
+
presented_first: str = Field(default="a", alias="presentedFirst")
|
|
777
|
+
flipped: bool = False
|
|
778
|
+
justification: Optional[str] = None
|
|
779
|
+
judge_model: Optional[str] = Field(default=None, alias="judgeModel")
|
|
780
|
+
|
|
781
|
+
class Config:
|
|
782
|
+
populate_by_name = True
|
|
783
|
+
extra = "ignore"
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
class PairwiseComparison(BaseModel):
|
|
787
|
+
"""A full head-to-head between two runs of the same dataset.
|
|
788
|
+
|
|
789
|
+
``skipped`` names the cases that could not be judged (one side produced no answer, or the
|
|
790
|
+
batch hit the server's per-comparison cap) with the reason - a comparison that quietly
|
|
791
|
+
dropped half the dataset would read as a clean sweep."""
|
|
792
|
+
|
|
793
|
+
batch_id: str = Field(alias="batchId")
|
|
794
|
+
run_a_id: str = Field(alias="runAId")
|
|
795
|
+
run_b_id: str = Field(alias="runBId")
|
|
796
|
+
both_orders: bool = Field(default=False, alias="bothOrders")
|
|
797
|
+
judge_model: Optional[str] = Field(default=None, alias="judgeModel")
|
|
798
|
+
summary: PairwiseSummary = Field(default_factory=PairwiseSummary)
|
|
799
|
+
cases: List[PairwiseCase] = Field(default_factory=list)
|
|
800
|
+
skipped: List[Dict[str, Any]] = Field(default_factory=list)
|
|
801
|
+
created_at: Optional[str] = Field(default=None, alias="createdAt")
|
|
802
|
+
|
|
803
|
+
class Config:
|
|
804
|
+
populate_by_name = True
|
|
805
|
+
extra = "ignore"
|
|
@@ -446,6 +446,20 @@ class EvaluationsRunner:
|
|
|
446
446
|
EvaluationsClient.simulate_conversation for parameters."""
|
|
447
447
|
return self._client.simulate_conversation(**kwargs)
|
|
448
448
|
|
|
449
|
+
def compare_pairwise(self, run_a_id: str, run_b_id: str, **kwargs):
|
|
450
|
+
"""Head-to-head judging between two runs of the same dataset - "which answer is better"
|
|
451
|
+
rather than two absolute scores. See EvaluationsClient.compare_pairwise for the options,
|
|
452
|
+
and agentx.testing.assert_pairwise for the pytest-side check."""
|
|
453
|
+
return self._client.compare_pairwise(run_a_id, run_b_id, **kwargs)
|
|
454
|
+
|
|
455
|
+
def get_pairwise(self, batch_id: str):
|
|
456
|
+
"""Read back a stored head-to-head by its batch id."""
|
|
457
|
+
return self._client.get_pairwise(batch_id)
|
|
458
|
+
|
|
459
|
+
def list_pairwise(self, **kwargs) -> list:
|
|
460
|
+
"""Summaries of past head-to-heads, newest first, optionally narrowed to one run."""
|
|
461
|
+
return self._client.list_pairwise(**kwargs)
|
|
462
|
+
|
|
449
463
|
def get_run(self, run_id: str) -> dict:
|
|
450
464
|
"""Run summary + per-result rows by id, without needing the EvaluationRunContext that
|
|
451
465
|
created it (e.g. from a separate process)."""
|
|
@@ -455,6 +455,7 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
455
455
|
input=node.get("input"),
|
|
456
456
|
output=node.get("output"),
|
|
457
457
|
error=node.get("error"),
|
|
458
|
+
span_kind="chain",
|
|
458
459
|
)
|
|
459
460
|
|
|
460
461
|
llm_count = 0
|
|
@@ -470,6 +471,7 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
470
471
|
model=step.get("model"),
|
|
471
472
|
input_tokens=step.get("inputTokenSize"),
|
|
472
473
|
output_tokens=step.get("outputTokenSize"),
|
|
474
|
+
span_kind="llm",
|
|
473
475
|
)
|
|
474
476
|
for tc in tool_calls:
|
|
475
477
|
resolve_parent(tc.get("parent_run_id")).child_span(
|
|
@@ -480,6 +482,7 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
480
482
|
input=tc.get("input"),
|
|
481
483
|
output=tc.get("output"),
|
|
482
484
|
error=None if tc.get("success", True) else str(tc.get("output") or "Tool call failed"),
|
|
485
|
+
span_kind="tool",
|
|
483
486
|
)
|
|
484
487
|
for step in state.get("retrieval_steps", []):
|
|
485
488
|
resolve_parent(step.get("parent_run_id")).child_span(
|
|
@@ -490,6 +493,7 @@ class AgentXCallbackHandler(BaseCallbackHandler):
|
|
|
490
493
|
input=step.get("query"),
|
|
491
494
|
output=step.get("output"),
|
|
492
495
|
metadata={"kind": "retrieval"},
|
|
496
|
+
span_kind="retrieval",
|
|
493
497
|
)
|
|
494
498
|
|
|
495
499
|
def on_chain_end(
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""pytest-friendly assertions over evaluation runs.
|
|
2
|
+
|
|
3
|
+
The DeepEval-style dev loop (``assert_test`` inside a pytest suite) on top of AgentX's existing
|
|
4
|
+
run + CI-gate primitives: run the evaluation however you like, then make the test fail with a
|
|
5
|
+
readable verdict when quality drops. No plugin registration needed - it's a plain function that
|
|
6
|
+
raises ``AssertionError``, so it works in any test runner and any CI.
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
from agentx import AgentX
|
|
11
|
+
from agentx.testing import assert_evaluation
|
|
12
|
+
|
|
13
|
+
def test_support_agent_quality():
|
|
14
|
+
client = AgentX.from_env()
|
|
15
|
+
report = (
|
|
16
|
+
client.evaluations
|
|
17
|
+
.run(dataset_id=DATASET_ID, scorer_id=SCORER_ID, subject=SUBJECT)
|
|
18
|
+
.execute(my_agent)
|
|
19
|
+
.finalize()
|
|
20
|
+
)
|
|
21
|
+
assert_evaluation(report, min_rating=7.0, no_regression=True)
|
|
22
|
+
|
|
23
|
+
The check rides the engine's CI gate, so every pytest verdict is also recorded in the
|
|
24
|
+
dashboard's gate history (CI Gates tab) with ``caller="pytest"`` - a red test and the
|
|
25
|
+
dashboard's gate row are the same event, not two systems drifting apart.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from typing import Any, List, Optional
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class EvaluationAssertionError(AssertionError):
|
|
32
|
+
"""Raised when an evaluation run fails its quality checks.
|
|
33
|
+
|
|
34
|
+
Subclasses ``AssertionError`` so pytest renders it as a plain test failure; carries the
|
|
35
|
+
``gate`` result for programmatic inspection in test hooks.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, message: str, gate: Any = None):
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.gate = gate
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _format_failures(gate: Any) -> str:
|
|
44
|
+
lines: List[str] = []
|
|
45
|
+
checks = getattr(gate, "checks", None) or []
|
|
46
|
+
for check in checks:
|
|
47
|
+
get = check.get if isinstance(check, dict) else lambda k, d=None: getattr(check, k, d)
|
|
48
|
+
status = "PASS" if get("passed") else "FAIL"
|
|
49
|
+
lines.append(f" [{status}] {get('name', 'check')}: {get('detail', '')}")
|
|
50
|
+
average = getattr(gate, "average_rating", None)
|
|
51
|
+
if average is not None:
|
|
52
|
+
lines.append(f" average rating: {average}")
|
|
53
|
+
return "\n".join(lines) if lines else f" gate: {gate!r}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def assert_evaluation(
|
|
57
|
+
report: Any,
|
|
58
|
+
*,
|
|
59
|
+
min_rating: Optional[float] = None,
|
|
60
|
+
no_regression: bool = False,
|
|
61
|
+
tolerance: Optional[float] = None,
|
|
62
|
+
caller: str = "pytest",
|
|
63
|
+
) -> Any:
|
|
64
|
+
"""Assert a finalized evaluation run meets its quality floor.
|
|
65
|
+
|
|
66
|
+
``report`` is the finalized :class:`~agentx.evaluations.runner.EvaluationRunContext`
|
|
67
|
+
returned by ``.execute(...).finalize()`` (or any object exposing the same ``.gate()``).
|
|
68
|
+
|
|
69
|
+
- ``min_rating`` - fail when the run's average judge rating is below this floor (0-10).
|
|
70
|
+
- ``no_regression`` - fail when the average dropped more than ``tolerance`` (default 0.5;
|
|
71
|
+
judge scores are noisy) below the dataset's previous completed run.
|
|
72
|
+
|
|
73
|
+
At least one check is required. Returns the ``GateResult`` on success; raises
|
|
74
|
+
:class:`EvaluationAssertionError` with a per-check verdict on failure.
|
|
75
|
+
"""
|
|
76
|
+
if min_rating is None and not no_regression:
|
|
77
|
+
raise ValueError("assert_evaluation needs at least one check: min_rating and/or no_regression=True")
|
|
78
|
+
gate = report.gate(
|
|
79
|
+
fail_under=min_rating,
|
|
80
|
+
no_regression=no_regression,
|
|
81
|
+
tolerance=tolerance,
|
|
82
|
+
caller=caller,
|
|
83
|
+
)
|
|
84
|
+
if getattr(gate, "passed", False):
|
|
85
|
+
return gate
|
|
86
|
+
run_id = getattr(report, "run_id", None) or getattr(getattr(report, "_run", None), "run_id", "?")
|
|
87
|
+
raise EvaluationAssertionError(
|
|
88
|
+
f"Evaluation run {run_id} failed its quality gate:\n{_format_failures(gate)}",
|
|
89
|
+
gate=gate,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _format_pairwise(comparison: Any) -> str:
|
|
94
|
+
summary = getattr(comparison, "summary", None)
|
|
95
|
+
lines: List[str] = []
|
|
96
|
+
if summary is not None:
|
|
97
|
+
flip = getattr(summary, "flip_rate", None)
|
|
98
|
+
lines.append(
|
|
99
|
+
f" A won {getattr(summary, 'a_wins', 0)}, B won {getattr(summary, 'b_wins', 0)}, "
|
|
100
|
+
f"{getattr(summary, 'ties', 0)} tied, out of {getattr(summary, 'total', 0)}"
|
|
101
|
+
+ (f" (flip rate {flip})" if flip is not None else "")
|
|
102
|
+
)
|
|
103
|
+
for case in getattr(comparison, "cases", None) or []:
|
|
104
|
+
if getattr(case, "winner", None) == "b":
|
|
105
|
+
query = (getattr(case, "query", None) or "").strip()
|
|
106
|
+
lines.append(f" lost: {query[:80]} - {(getattr(case, 'justification', None) or '')[:120]}")
|
|
107
|
+
return "\n".join(lines) if lines else f" comparison: {comparison!r}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def assert_pairwise(
|
|
111
|
+
comparison: Any,
|
|
112
|
+
*,
|
|
113
|
+
must_win: bool = False,
|
|
114
|
+
max_losses: Optional[int] = None,
|
|
115
|
+
max_flip_rate: Optional[float] = None,
|
|
116
|
+
) -> Any:
|
|
117
|
+
"""Assert a head-to-head comparison went the candidate's way.
|
|
118
|
+
|
|
119
|
+
``comparison`` is what ``client.evaluations.compare_pairwise(a, b)`` returns; run A is the
|
|
120
|
+
candidate and run B is the baseline it has to beat.
|
|
121
|
+
|
|
122
|
+
- ``must_win`` - fail unless A won more cases than B. A tie fails: "no worse than before" is
|
|
123
|
+
not the same claim as "better", and a change that cannot win its own comparison has not
|
|
124
|
+
earned a green test.
|
|
125
|
+
- ``max_losses`` - fail when A lost more than this many individual cases, even if it won
|
|
126
|
+
overall. This is the check that catches a change that lifts the average by improving easy
|
|
127
|
+
cases while breaking hard ones.
|
|
128
|
+
- ``max_flip_rate`` - fail when too many verdicts reversed with the presentation order. That
|
|
129
|
+
is position bias rather than quality, and it means the comparison itself is inconclusive,
|
|
130
|
+
so treating it as a pass would be worse than a red test. Only meaningful for a comparison
|
|
131
|
+
run with ``both_orders=True``; a comparison without it has no flip rate and this check is
|
|
132
|
+
skipped rather than quietly passing.
|
|
133
|
+
|
|
134
|
+
At least one check is required. Returns the comparison on success; raises
|
|
135
|
+
:class:`EvaluationAssertionError` naming the cases that lost.
|
|
136
|
+
"""
|
|
137
|
+
if not must_win and max_losses is None and max_flip_rate is None:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
"assert_pairwise needs at least one check: must_win, max_losses, and/or max_flip_rate"
|
|
140
|
+
)
|
|
141
|
+
summary = getattr(comparison, "summary", None)
|
|
142
|
+
if summary is None:
|
|
143
|
+
raise ValueError("assert_pairwise expects the result of compare_pairwise()")
|
|
144
|
+
|
|
145
|
+
failures: List[str] = []
|
|
146
|
+
a_wins = getattr(summary, "a_wins", 0)
|
|
147
|
+
b_wins = getattr(summary, "b_wins", 0)
|
|
148
|
+
if must_win and a_wins <= b_wins:
|
|
149
|
+
failures.append(f"run A did not win ({a_wins} vs {b_wins})")
|
|
150
|
+
if max_losses is not None and b_wins > max_losses:
|
|
151
|
+
failures.append(f"run A lost {b_wins} cases, more than the {max_losses} allowed")
|
|
152
|
+
flip_rate = getattr(summary, "flip_rate", None)
|
|
153
|
+
if max_flip_rate is not None and flip_rate is not None and flip_rate > max_flip_rate:
|
|
154
|
+
failures.append(
|
|
155
|
+
f"verdicts flipped on {flip_rate:.0%} of cases with the presentation order, above the "
|
|
156
|
+
f"{max_flip_rate:.0%} allowed - this comparison is inconclusive, not a pass"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if not failures:
|
|
160
|
+
return comparison
|
|
161
|
+
batch_id = getattr(comparison, "batch_id", "?")
|
|
162
|
+
raise EvaluationAssertionError(
|
|
163
|
+
f"Head-to-head {batch_id} failed: {'; '.join(failures)}\n{_format_pairwise(comparison)}",
|
|
164
|
+
gate=comparison,
|
|
165
|
+
)
|
|
@@ -71,6 +71,7 @@ class _TraceSpan:
|
|
|
71
71
|
monitor: bool = False,
|
|
72
72
|
pattern_ids: Optional[List[str]] = None,
|
|
73
73
|
agent_id: Optional[str] = None,
|
|
74
|
+
span_kind: Optional[str] = None,
|
|
74
75
|
) -> None:
|
|
75
76
|
self._tracer = tracer
|
|
76
77
|
self.name = name
|
|
@@ -84,6 +85,10 @@ class _TraceSpan:
|
|
|
84
85
|
# from a prior GET /agents lookup) to pin this trace to that exact agent. None (the
|
|
85
86
|
# default) resolves from `name` alone server-side, one stable agent per distinct name.
|
|
86
87
|
self._agent_id = agent_id
|
|
88
|
+
# What kind of step this span is ("agent", "llm", ...), stated rather than left to the
|
|
89
|
+
# backend's fallback ladder. Optional: a root that says nothing still classifies the way
|
|
90
|
+
# it always did, which for the common flat trace (root carries the model) is "llm".
|
|
91
|
+
self._span_kind = span_kind
|
|
87
92
|
# When True, __exit__ sends synchronously (blocking) instead of enqueueing, so trace_id
|
|
88
93
|
# is populated by the time the `with` block exits - see Tracer.trace()'s sync param.
|
|
89
94
|
self._sync = sync
|
|
@@ -183,6 +188,7 @@ class _TraceSpan:
|
|
|
183
188
|
cache_write_tokens=self._cache_write_tokens or None,
|
|
184
189
|
span_id=self._span_id,
|
|
185
190
|
parent_span_id=self._parent_span_id,
|
|
191
|
+
span_kind=self._span_kind,
|
|
186
192
|
started_at_unix_nano=str(int(self._start * 1_000_000_000)) if self._start else None,
|
|
187
193
|
)
|
|
188
194
|
return False # never suppress exceptions
|
|
@@ -262,6 +268,7 @@ class _TraceSpan:
|
|
|
262
268
|
error: Optional[str] = None,
|
|
263
269
|
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
|
264
270
|
metadata: Optional[Dict[str, Any]] = None,
|
|
271
|
+
span_kind: Optional[str] = None,
|
|
265
272
|
) -> "_TraceSpan":
|
|
266
273
|
"""
|
|
267
274
|
Send one real child-span row parented to this span, with explicit timing (the caller's
|
|
@@ -318,6 +325,11 @@ class _TraceSpan:
|
|
|
318
325
|
wire["tool_calls"] = child.tool_calls
|
|
319
326
|
if metadata:
|
|
320
327
|
wire["metadata"] = _safe_serialize(metadata)
|
|
328
|
+
# What kind of step this is, stated rather than left for the backend to guess from the
|
|
329
|
+
# span's name and which columns happen to be null. Same idea as LangSmith's run_type and
|
|
330
|
+
# Langfuse's observation type; the engine folds other vocabularies onto its own.
|
|
331
|
+
if span_kind:
|
|
332
|
+
wire["span_kind"] = span_kind
|
|
321
333
|
if child._session_id:
|
|
322
334
|
wire["session_id"] = child._session_id
|
|
323
335
|
wire["span_id"] = child._span_id
|
|
@@ -383,6 +395,9 @@ class _TraceSpan:
|
|
|
383
395
|
output_tokens=step.get("outputTokenSize"),
|
|
384
396
|
cache_read_tokens=step.get("cacheReadTokenSize"),
|
|
385
397
|
cache_write_tokens=step.get("cacheWriteTokenSize"),
|
|
398
|
+
# Stated, so a step named anything other than "LLM Call N" still classifies -
|
|
399
|
+
# the backend's name regex was the only thing holding this together.
|
|
400
|
+
span_kind="llm",
|
|
386
401
|
)
|
|
387
402
|
for tc in tool_calls or []:
|
|
388
403
|
# Some callers' tool_calls dicts (e.g. langchain.py's, which sets these on the
|
|
@@ -399,6 +414,7 @@ class _TraceSpan:
|
|
|
399
414
|
input=tc.get("input"),
|
|
400
415
|
output=tc.get("output"),
|
|
401
416
|
error=None if tc.get("success", True) else str(tc.get("output") or "Tool call failed"),
|
|
417
|
+
span_kind="tool",
|
|
402
418
|
)
|
|
403
419
|
# Also mirror onto this span's own flat tool_calls list, sent in this span's own
|
|
404
420
|
# wire payload on __exit__ (see tool_calls=self.tool_calls or None below). The
|
|
@@ -424,6 +440,7 @@ class _TraceSpan:
|
|
|
424
440
|
input=step.get("query"),
|
|
425
441
|
output=step.get("output"),
|
|
426
442
|
metadata={"kind": "retrieval"},
|
|
443
|
+
span_kind="retrieval",
|
|
427
444
|
)
|
|
428
445
|
|
|
429
446
|
if self.input is None and input is not None:
|
|
@@ -665,6 +682,7 @@ class Tracer:
|
|
|
665
682
|
input=input,
|
|
666
683
|
output=output,
|
|
667
684
|
error=error,
|
|
685
|
+
span_kind="tool",
|
|
668
686
|
)
|
|
669
687
|
# The child span above is only for the trace detail's span tree - the engine's
|
|
670
688
|
# built-in "Tool failure" check and the dashboard's Tool quality column read the
|
|
@@ -781,6 +799,7 @@ class Tracer:
|
|
|
781
799
|
input=query,
|
|
782
800
|
output=output,
|
|
783
801
|
metadata={"kind": "retrieval"},
|
|
802
|
+
span_kind="retrieval",
|
|
784
803
|
)
|
|
785
804
|
|
|
786
805
|
@contextmanager
|
|
@@ -823,6 +842,7 @@ class Tracer:
|
|
|
823
842
|
monitor: Optional[bool] = None,
|
|
824
843
|
pattern_ids: Optional[List[str]] = None,
|
|
825
844
|
agent_id: Optional[str] = None,
|
|
845
|
+
span_kind: Optional[str] = None,
|
|
826
846
|
) -> _TraceSpan:
|
|
827
847
|
"""
|
|
828
848
|
Return a :class:`_TraceSpan` that works as both a decorator and a
|
|
@@ -888,6 +908,7 @@ class Tracer:
|
|
|
888
908
|
monitor=monitor,
|
|
889
909
|
pattern_ids=pattern_ids,
|
|
890
910
|
agent_id=agent_id,
|
|
911
|
+
span_kind=span_kind,
|
|
891
912
|
)
|
|
892
913
|
|
|
893
914
|
def flush(self, timeout: float = 5.0) -> bool:
|
|
@@ -1127,6 +1148,8 @@ class Tracer:
|
|
|
1127
1148
|
wire["started_at_unix_nano"] = payload["started_at_unix_nano"]
|
|
1128
1149
|
if "agent_id" in payload:
|
|
1129
1150
|
wire["agent_id"] = payload["agent_id"]
|
|
1151
|
+
if "span_kind" in payload:
|
|
1152
|
+
wire["span_kind"] = payload["span_kind"]
|
|
1130
1153
|
|
|
1131
1154
|
pending_tool_calls, self._pending_tool_calls = self._pending_tool_calls, []
|
|
1132
1155
|
if pending_tool_calls:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = "0.8.2"
|
|
@@ -10,6 +10,7 @@ agentx/feedback.py
|
|
|
10
10
|
agentx/outcomes.py
|
|
11
11
|
agentx/projects.py
|
|
12
12
|
agentx/py.typed
|
|
13
|
+
agentx/testing.py
|
|
13
14
|
agentx/traces.py
|
|
14
15
|
agentx/util.py
|
|
15
16
|
agentx/version.py
|
|
@@ -74,5 +75,7 @@ tests/test_docs_match_sdk.py
|
|
|
74
75
|
tests/test_integration.py
|
|
75
76
|
tests/test_integrations.py
|
|
76
77
|
tests/test_judge_scorers.py
|
|
78
|
+
tests/test_pairwise.py
|
|
77
79
|
tests/test_selfhost_analysis_fallback.py
|
|
78
|
-
tests/test_span_tree.py
|
|
80
|
+
tests/test_span_tree.py
|
|
81
|
+
tests/test_testing.py
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Head-to-head judging: the client's request shape and the pytest assertion over the result."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from agentx.evaluations.models import PairwiseComparison
|
|
6
|
+
from agentx.testing import EvaluationAssertionError, assert_pairwise
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def comparison(**over) -> PairwiseComparison:
|
|
10
|
+
payload = {
|
|
11
|
+
"batchId": "batch-1",
|
|
12
|
+
"runAId": "run-candidate",
|
|
13
|
+
"runBId": "run-baseline",
|
|
14
|
+
"bothOrders": False,
|
|
15
|
+
"judgeModel": "gpt-5.6-luna",
|
|
16
|
+
"summary": {"total": 3, "aWins": 2, "bWins": 1, "ties": 0, "winner": "a", "flipRate": None},
|
|
17
|
+
"cases": [
|
|
18
|
+
{
|
|
19
|
+
"questionIndex": 2,
|
|
20
|
+
"query": "Who pays return shipping?",
|
|
21
|
+
"winner": "b",
|
|
22
|
+
"presentedFirst": "a",
|
|
23
|
+
"justification": "Answer 2 names both cases explicitly.",
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"skipped": [],
|
|
27
|
+
}
|
|
28
|
+
payload.update(over)
|
|
29
|
+
return PairwiseComparison(**payload)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FakeClient:
|
|
33
|
+
"""Captures the request instead of sending it - the wire shape is the contract with the
|
|
34
|
+
engine, and camelCase is the convention it has to keep."""
|
|
35
|
+
|
|
36
|
+
# The pairwise routes live on the /evaluate dialect, reached through this property.
|
|
37
|
+
_api_root = "https://engine.example/api/v1"
|
|
38
|
+
|
|
39
|
+
def __init__(self, response=None):
|
|
40
|
+
self.calls = []
|
|
41
|
+
self._response = response or {"comparison": comparison().model_dump(by_alias=True)}
|
|
42
|
+
|
|
43
|
+
def _request(self, method, path, **kwargs):
|
|
44
|
+
self.calls.append((method, path, kwargs))
|
|
45
|
+
return self._response
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_compare_pairwise_sends_camelcase_and_omits_unset_options():
|
|
49
|
+
from agentx.evaluations.client import EvaluationsClient
|
|
50
|
+
|
|
51
|
+
client = FakeClient()
|
|
52
|
+
result = EvaluationsClient.compare_pairwise(client, "run-candidate", "run-baseline")
|
|
53
|
+
|
|
54
|
+
method, path, kwargs = client.calls[0]
|
|
55
|
+
assert (method, path) == ("POST", "/evaluate/runs/pairwise")
|
|
56
|
+
# Defaults are the server's to choose; the SDK does not invent a criteria string or a
|
|
57
|
+
# judge model, and does not send bothOrders unless the caller asked for it.
|
|
58
|
+
assert kwargs["json"] == {"runAId": "run-candidate", "runBId": "run-baseline"}
|
|
59
|
+
assert result.summary.a_wins == 2
|
|
60
|
+
assert result.cases[0].presented_first == "a"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_compare_pairwise_forwards_the_options_it_is_given():
|
|
64
|
+
from agentx.evaluations.client import EvaluationsClient
|
|
65
|
+
|
|
66
|
+
client = FakeClient()
|
|
67
|
+
EvaluationsClient.compare_pairwise(
|
|
68
|
+
client, "a", "b", criteria="Which is more concise?", judge_model="gpt-5.6-luna", both_orders=True
|
|
69
|
+
)
|
|
70
|
+
assert client.calls[0][2]["json"] == {
|
|
71
|
+
"runAId": "a",
|
|
72
|
+
"runBId": "b",
|
|
73
|
+
"criteria": "Which is more concise?",
|
|
74
|
+
"judgeModel": "gpt-5.6-luna",
|
|
75
|
+
"bothOrders": True,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_assert_pairwise_passes_a_clear_win():
|
|
80
|
+
result = assert_pairwise(comparison(), must_win=True, max_losses=1)
|
|
81
|
+
assert result.summary.winner == "a"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_a_tie_is_not_a_win():
|
|
85
|
+
tied = comparison(summary={"total": 2, "aWins": 1, "bWins": 1, "ties": 0, "winner": "tie", "flipRate": None})
|
|
86
|
+
with pytest.raises(EvaluationAssertionError) as excinfo:
|
|
87
|
+
assert_pairwise(tied, must_win=True)
|
|
88
|
+
assert "did not win" in str(excinfo.value)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_max_losses_catches_a_win_that_broke_hard_cases():
|
|
92
|
+
# Wins overall, but lost more individual cases than the caller tolerates.
|
|
93
|
+
lossy = comparison(summary={"total": 10, "aWins": 5, "bWins": 4, "ties": 1, "winner": "a", "flipRate": None})
|
|
94
|
+
with pytest.raises(EvaluationAssertionError) as excinfo:
|
|
95
|
+
assert_pairwise(lossy, max_losses=2)
|
|
96
|
+
message = str(excinfo.value)
|
|
97
|
+
assert "lost 4 cases" in message
|
|
98
|
+
# The failure names the case that lost, so the test output is actionable on its own.
|
|
99
|
+
assert "Who pays return shipping?" in message
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_a_high_flip_rate_fails_instead_of_passing_on_position_bias():
|
|
103
|
+
biased = comparison(
|
|
104
|
+
bothOrders=True,
|
|
105
|
+
summary={"total": 4, "aWins": 3, "bWins": 1, "ties": 0, "winner": "a", "flipRate": 0.5},
|
|
106
|
+
)
|
|
107
|
+
with pytest.raises(EvaluationAssertionError) as excinfo:
|
|
108
|
+
assert_pairwise(biased, max_flip_rate=0.2)
|
|
109
|
+
assert "inconclusive" in str(excinfo.value)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_flip_rate_check_is_skipped_when_both_orders_was_not_run():
|
|
113
|
+
# No flip rate exists to check, so this must not silently fail or silently pass a made-up 0.
|
|
114
|
+
assert_pairwise(comparison(), max_flip_rate=0.0)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_requires_at_least_one_check():
|
|
118
|
+
with pytest.raises(ValueError):
|
|
119
|
+
assert_pairwise(comparison())
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""agentx.testing.assert_evaluation - the pytest-native quality gate wrapper."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from agentx.evaluations.runner import GateResult
|
|
6
|
+
from agentx.testing import EvaluationAssertionError, assert_evaluation
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FakeReport:
|
|
10
|
+
def __init__(self, gate_payload):
|
|
11
|
+
self.run_id = "run-123"
|
|
12
|
+
self.gate_kwargs = None
|
|
13
|
+
self._payload = gate_payload
|
|
14
|
+
|
|
15
|
+
def gate(self, **kwargs):
|
|
16
|
+
self.gate_kwargs = kwargs
|
|
17
|
+
return GateResult(self._payload)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_passing_gate_returns_result_and_forwards_checks():
|
|
21
|
+
report = FakeReport({"passed": True, "averageRating": 8.2, "checks": [{"name": "floor", "passed": True}]})
|
|
22
|
+
gate = assert_evaluation(report, min_rating=7.0, no_regression=True, tolerance=0.3)
|
|
23
|
+
assert gate.passed is True
|
|
24
|
+
assert report.gate_kwargs == {"fail_under": 7.0, "no_regression": True, "tolerance": 0.3, "caller": "pytest"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_failing_gate_raises_assertion_error_with_verdict():
|
|
28
|
+
report = FakeReport({
|
|
29
|
+
"passed": False,
|
|
30
|
+
"averageRating": 5.1,
|
|
31
|
+
"checks": [
|
|
32
|
+
{"name": "floor", "passed": False, "detail": "average 5.1 below fail_under 7"},
|
|
33
|
+
{"name": "regression", "passed": True, "detail": "no baseline"},
|
|
34
|
+
],
|
|
35
|
+
})
|
|
36
|
+
with pytest.raises(EvaluationAssertionError) as excinfo:
|
|
37
|
+
assert_evaluation(report, min_rating=7.0)
|
|
38
|
+
message = str(excinfo.value)
|
|
39
|
+
assert "run-123" in message
|
|
40
|
+
assert "[FAIL] floor" in message
|
|
41
|
+
assert "average 5.1 below fail_under 7" in message
|
|
42
|
+
# AssertionError subclass, so pytest treats it as a normal test failure.
|
|
43
|
+
assert isinstance(excinfo.value, AssertionError)
|
|
44
|
+
assert excinfo.value.gate.average_rating == 5.1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_requires_at_least_one_check():
|
|
48
|
+
with pytest.raises(ValueError):
|
|
49
|
+
assert_evaluation(FakeReport({"passed": True}))
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
VERSION = "0.6.39"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|