alignmenter 0.3.3__tar.gz → 0.3.4__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.
- {alignmenter-0.3.3/src/alignmenter.egg-info → alignmenter-0.3.4}/PKG-INFO +1 -1
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/_version.py +1 -1
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/cli.py +2 -0
- alignmenter-0.3.4/src/alignmenter/rubric_grade.py +408 -0
- alignmenter-0.3.4/src/alignmenter/rubric_grade_cli.py +105 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4/src/alignmenter.egg-info}/PKG-INFO +1 -1
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter.egg-info/SOURCES.txt +3 -0
- alignmenter-0.3.4/tests/test_rubric_grade.py +311 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/LICENSE +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/MANIFEST.in +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/README.md +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/configs/demo_config.yaml +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/configs/judges/safety_prompt.txt +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/configs/persona/default.yaml +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/configs/run-grounded.yaml +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/configs/run.yaml +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/configs/safety_keywords.yaml +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/datasets/README.md +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/datasets/demo_conversations.jsonl +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/datasets/grounded_demo.jsonl +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/datasets/wendys_twitter.jsonl +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/pyproject.toml +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/setup.cfg +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/__init__.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/calibration/__init__.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/calibration/analyze.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/calibration/bounds.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/calibration/diagnose.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/calibration/generate.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/calibration/label.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/calibration/optimize.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/calibration/sampling.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/calibration/validate.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/config.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/data/configs/demo_config.yaml +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/data/configs/judges/safety_prompt.txt +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/data/configs/persona/default.yaml +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/data/configs/run-grounded.yaml +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/data/configs/run.yaml +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/data/configs/safety_keywords.yaml +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/data/datasets/demo_conversations.jsonl +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/data/datasets/grounded_demo.jsonl +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/dataset_cli.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/evaluators/__init__.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/evaluators/custom.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/evaluators/evidence.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/evaluators/faithfulness.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/evaluators/grounding.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/evaluators/metrics.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/examples/__init__.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/examples/resource_task.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/execution/__init__.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/execution/archive.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/execution/artifacts.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/execution/comparison.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/execution/evaluation.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/execution/gates.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/execution/leases.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/execution/legacy.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/execution/recovery.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/execution/review.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/execution/suite.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/importers/__init__.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/importers/healthbench.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/judges/__init__.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/judges/authenticity_judge.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/judges/prompts.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/providers/__init__.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/providers/anthropic.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/providers/base.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/providers/callable.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/providers/classifiers.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/providers/durable_judge.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/providers/embeddings.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/providers/judges.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/providers/local.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/providers/openai.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/release_cli.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/reporting/__init__.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/reporting/durable.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/reporting/github_comment.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/reporting/html.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/reporting/json_out.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/run_config.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/runner.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/schemas/__init__.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/schemas/dataset.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/schemas/evaluation.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/schemas/execution.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/schemas/gates.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/schemas/metrics.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/schemas/review.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/schemas/scoring.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/schemas/suite.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/scorers/__init__.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/scorers/authenticity.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/scorers/faithfulness.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/scorers/grounding.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/scorers/safety.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/scorers/stability.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/scripts/__init__.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/scripts/bootstrap_dataset.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/scripts/calibrate_persona.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/scripts/run_openai_demo.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/scripts/sanitize_dataset.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/sdk.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/storage/__init__.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/storage/evaluations.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/storage/reviews.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/storage/runs.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/utils/__init__.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/utils/io.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/utils/optional.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/utils/tokens.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/utils/yaml.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter.egg-info/dependency_links.txt +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter.egg-info/entry_points.txt +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter.egg-info/requires.txt +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter.egg-info/top_level.txt +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/__init__.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/conftest.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/data/durable_evaluation_judge.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/data/durable_evaluation_worker.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/data/durable_recovery_target.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/data/durable_recovery_worker.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/data/durable_run_worker.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/data/mini_cli_dataset.jsonl +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_authenticity_judge.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_builtin_evaluations.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_calibrate_persona.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_capture_recovery.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_cli_errors.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_cli_grounded.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_cli_helpers.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_cli_import.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_cli_init.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_cli_run_config.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_config.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_dataset_import.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_dataset_management.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_dataset_sample.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_durable_evaluations.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_durable_execution.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_faithfulness.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_github_comment.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_grounding.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_html_report.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_judge_providers.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_offline_safety.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_persona_gpt.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_provider_local.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_provider_openai.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_providers.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_release_workflow.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_review_workflow.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_run_config_grounded.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_run_config_loader.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_run_openai_demo.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_runner.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_sampling.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_scorers.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_smoke.py +0 -0
- {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_suite_archive.py +0 -0
|
@@ -32,6 +32,7 @@ from alignmenter.providers.classifiers import load_safety_classifier
|
|
|
32
32
|
from alignmenter.providers.judges import load_judge_provider
|
|
33
33
|
from alignmenter.providers.openai import OpenAICustomGPTProvider
|
|
34
34
|
from alignmenter.release_cli import register_release_commands
|
|
35
|
+
from alignmenter.rubric_grade_cli import register_rubric_grade_command
|
|
35
36
|
from alignmenter.run_config import load_run_options
|
|
36
37
|
from alignmenter.runner import RunConfig, Runner
|
|
37
38
|
from alignmenter.schemas.evaluation import EvaluationSpec, JudgeBudget, JudgeContract
|
|
@@ -48,6 +49,7 @@ from alignmenter.storage.runs import RunStore
|
|
|
48
49
|
|
|
49
50
|
app = typer.Typer(help="Alignmenter — audit your model's alignment signals.")
|
|
50
51
|
register_release_commands(app)
|
|
52
|
+
register_rubric_grade_command(app)
|
|
51
53
|
|
|
52
54
|
persona_app = typer.Typer(help="Persona helper commands.")
|
|
53
55
|
dataset_app = typer.Typer(help="Dataset helper commands.")
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""Decomposed rubric grading — score a captured response against per-record rubrics.
|
|
2
|
+
|
|
3
|
+
HealthBench-style corpora attach physician rubrics to each case (in a turn's
|
|
4
|
+
``metadata.rubrics``) with no reference answer. This grades a captured response
|
|
5
|
+
against each rubric criterion INDEPENDENTLY: one narrow "is this one criterion
|
|
6
|
+
met?" judge call per criterion. Making each judgment mechanical — a single yes/no
|
|
7
|
+
with evidence — is what lets a *cheap* judge model do it reliably. Grading is
|
|
8
|
+
budget-capped by call count, and an agreement mode compares a cheap judge against
|
|
9
|
+
a strong one so the cheap judge is proven before it is trusted.
|
|
10
|
+
|
|
11
|
+
This is deliberately standalone rather than wired into ``evaluate_saved``: that
|
|
12
|
+
engine plans one item per *static* spec criterion, whereas these rubrics are
|
|
13
|
+
per-record and dynamic. Keeping it separate keeps it cheap and low-risk; the
|
|
14
|
+
prompt/verdict/scoring pieces here are reusable if it later graduates into the
|
|
15
|
+
engine.
|
|
16
|
+
|
|
17
|
+
Judge interface: any object with ``evaluate(prompt: str) -> {"notes": str, ...}``
|
|
18
|
+
— the ``JudgeProvider`` contract in ``providers/judges.py``. Tests inject a fake;
|
|
19
|
+
the CLI wires an ``OpenAIJudge`` whose client points at the Vercel AI Gateway, so
|
|
20
|
+
the judge model is any ``provider/model`` string (e.g. ``anthropic/claude-haiku-4.5``).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
from collections.abc import Callable, Iterable
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Protocol
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Judge(Protocol):
|
|
32
|
+
def evaluate(self, prompt: str) -> dict: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# --- data ------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Rubric:
|
|
40
|
+
criterion: str
|
|
41
|
+
points: float = 1.0
|
|
42
|
+
tags: tuple[str, ...] = ()
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_raw(cls, raw: dict) -> Rubric | None:
|
|
46
|
+
criterion = raw.get("criterion")
|
|
47
|
+
if not isinstance(criterion, str) or not criterion.strip():
|
|
48
|
+
return None
|
|
49
|
+
points = raw.get("points", 1.0)
|
|
50
|
+
try:
|
|
51
|
+
points = float(points)
|
|
52
|
+
except (TypeError, ValueError):
|
|
53
|
+
points = 1.0
|
|
54
|
+
tags = tuple(t for t in (raw.get("tags") or []) if isinstance(t, str))
|
|
55
|
+
return cls(criterion=criterion.strip(), points=points, tags=tags)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Case:
|
|
60
|
+
case_id: str
|
|
61
|
+
question: str
|
|
62
|
+
response: str
|
|
63
|
+
rubrics: tuple[Rubric, ...]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class CriterionVerdict:
|
|
68
|
+
criterion: str
|
|
69
|
+
points: float
|
|
70
|
+
met: bool | None # None = not graded (budget-blocked or unparseable)
|
|
71
|
+
confidence: float | None
|
|
72
|
+
evidence: str
|
|
73
|
+
status: str # "graded" | "budget_blocked" | "invalid"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class CaseGrade:
|
|
78
|
+
case_id: str
|
|
79
|
+
verdicts: list[CriterionVerdict]
|
|
80
|
+
score: float | None # HealthBench-style normalized 0..1 (None if nothing graded)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class GradeReport:
|
|
85
|
+
cases: list[CaseGrade] = field(default_factory=list)
|
|
86
|
+
calls: int = 0
|
|
87
|
+
budget_blocked: int = 0
|
|
88
|
+
invalid: int = 0
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def graded_cases(self) -> list[CaseGrade]:
|
|
92
|
+
return [c for c in self.cases if c.score is not None]
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def mean_score(self) -> float | None:
|
|
96
|
+
scored = [c.score for c in self.graded_cases]
|
|
97
|
+
return sum(scored) / len(scored) if scored else None
|
|
98
|
+
|
|
99
|
+
def to_dict(self) -> dict:
|
|
100
|
+
mean = self.mean_score
|
|
101
|
+
return {
|
|
102
|
+
"n_cases": len(self.cases),
|
|
103
|
+
"n_graded_cases": len(self.graded_cases),
|
|
104
|
+
"mean_score": round(mean, 4) if mean is not None else None,
|
|
105
|
+
"calls": self.calls,
|
|
106
|
+
"budget_blocked": self.budget_blocked,
|
|
107
|
+
"invalid": self.invalid,
|
|
108
|
+
"cases": [
|
|
109
|
+
{
|
|
110
|
+
"case_id": c.case_id,
|
|
111
|
+
"score": round(c.score, 4) if c.score is not None else None,
|
|
112
|
+
"verdicts": [
|
|
113
|
+
{
|
|
114
|
+
"criterion": v.criterion,
|
|
115
|
+
"points": v.points,
|
|
116
|
+
"met": v.met,
|
|
117
|
+
"confidence": v.confidence,
|
|
118
|
+
"status": v.status,
|
|
119
|
+
"evidence": v.evidence,
|
|
120
|
+
}
|
|
121
|
+
for v in c.verdicts
|
|
122
|
+
],
|
|
123
|
+
}
|
|
124
|
+
for c in self.cases
|
|
125
|
+
],
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --- extracting gradeable cases from a captured dataset --------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _turn_index(turn: dict) -> int:
|
|
133
|
+
"""Sort key tolerant of a missing/non-int turn_index (never crashes the run)."""
|
|
134
|
+
ti = turn.get("turn_index")
|
|
135
|
+
if isinstance(ti, bool) or not isinstance(ti, int):
|
|
136
|
+
try:
|
|
137
|
+
return int(ti)
|
|
138
|
+
except (TypeError, ValueError):
|
|
139
|
+
return 0
|
|
140
|
+
return ti
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def extract_cases(records: Iterable[dict]) -> list[Case]:
|
|
144
|
+
"""Pull (question, response, rubrics) cases from a captured dataset.
|
|
145
|
+
|
|
146
|
+
A case is one session that carries ``metadata.rubrics`` (the importer puts
|
|
147
|
+
them on the final user turn) AND has a captured assistant response. Sessions
|
|
148
|
+
without rubrics or without a response are skipped — they can't be graded.
|
|
149
|
+
"""
|
|
150
|
+
by_session: dict[str, list[dict]] = {}
|
|
151
|
+
order: list[str] = []
|
|
152
|
+
for r in records:
|
|
153
|
+
if not isinstance(r, dict):
|
|
154
|
+
continue
|
|
155
|
+
sid = r.get("session_id")
|
|
156
|
+
if not isinstance(sid, str):
|
|
157
|
+
continue
|
|
158
|
+
if sid not in by_session:
|
|
159
|
+
by_session[sid] = []
|
|
160
|
+
order.append(sid)
|
|
161
|
+
by_session[sid].append(r)
|
|
162
|
+
|
|
163
|
+
cases: list[Case] = []
|
|
164
|
+
for sid in order:
|
|
165
|
+
turns = sorted(by_session[sid], key=_turn_index)
|
|
166
|
+
rubric_turn = next(
|
|
167
|
+
(t for t in turns if isinstance((t.get("metadata") or {}).get("rubrics"), list)), None
|
|
168
|
+
)
|
|
169
|
+
if rubric_turn is None:
|
|
170
|
+
continue
|
|
171
|
+
rubrics = tuple(
|
|
172
|
+
r
|
|
173
|
+
for raw in rubric_turn["metadata"]["rubrics"]
|
|
174
|
+
if isinstance(raw, dict)
|
|
175
|
+
for r in (Rubric.from_raw(raw),)
|
|
176
|
+
if r is not None
|
|
177
|
+
)
|
|
178
|
+
if not rubrics:
|
|
179
|
+
continue
|
|
180
|
+
# The captured response is the last assistant turn with real text (a
|
|
181
|
+
# trailing blank turn shouldn't discard a case that did answer earlier).
|
|
182
|
+
response = next(
|
|
183
|
+
(
|
|
184
|
+
t["text"]
|
|
185
|
+
for t in reversed(turns)
|
|
186
|
+
if t.get("role") == "assistant"
|
|
187
|
+
and isinstance(t.get("text"), str)
|
|
188
|
+
and t["text"].strip()
|
|
189
|
+
),
|
|
190
|
+
"",
|
|
191
|
+
)
|
|
192
|
+
if not response.strip():
|
|
193
|
+
continue
|
|
194
|
+
question = rubric_turn.get("text", "") if isinstance(rubric_turn.get("text"), str) else ""
|
|
195
|
+
cases.append(Case(case_id=sid, question=question, response=response, rubrics=rubrics))
|
|
196
|
+
return cases
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# --- one narrow judgment per criterion -------------------------------------
|
|
200
|
+
|
|
201
|
+
CRITERION_SYSTEM = (
|
|
202
|
+
"You grade one health-assistant reply against ONE rubric criterion. Decide only "
|
|
203
|
+
"whether the reply satisfies that single criterion — nothing else. Quote the reply "
|
|
204
|
+
"as evidence. Respond with JSON only."
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def build_criterion_prompt(response: str, rubric: Rubric, *, question: str = "") -> str:
|
|
209
|
+
"""A narrow, mechanical single-criterion grading prompt (cheap-model friendly)."""
|
|
210
|
+
payload = {
|
|
211
|
+
"task": "Does the assistant reply satisfy this one criterion?",
|
|
212
|
+
"criterion": rubric.criterion,
|
|
213
|
+
"user_question": question,
|
|
214
|
+
"assistant_reply": response,
|
|
215
|
+
"instructions": (
|
|
216
|
+
"Answer for THIS criterion only. 'met' is true only if the reply clearly "
|
|
217
|
+
"satisfies it. Put a short quote from the reply in 'evidence' (empty if not met)."
|
|
218
|
+
),
|
|
219
|
+
"response_schema": {
|
|
220
|
+
"met": "boolean",
|
|
221
|
+
"confidence": "number 0..1",
|
|
222
|
+
"evidence": "string (<=200 chars, quoted from the reply)",
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
return json.dumps(payload, ensure_ascii=False)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _extract_json(text: str) -> dict | None:
|
|
229
|
+
"""Tolerant JSON extraction — raw, ```json-fenced, or prose-wrapped."""
|
|
230
|
+
t = (text or "").strip()
|
|
231
|
+
if "```" in t:
|
|
232
|
+
parts = t.split("```", 2)
|
|
233
|
+
if len(parts) >= 2:
|
|
234
|
+
body = parts[1]
|
|
235
|
+
if body.startswith("json"):
|
|
236
|
+
body = body[4:]
|
|
237
|
+
t = body.strip()
|
|
238
|
+
start = t.find("{")
|
|
239
|
+
if start != -1:
|
|
240
|
+
t = t[start:]
|
|
241
|
+
try:
|
|
242
|
+
data = json.loads(t)
|
|
243
|
+
except (json.JSONDecodeError, TypeError):
|
|
244
|
+
try:
|
|
245
|
+
data, _ = json.JSONDecoder().raw_decode(t)
|
|
246
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
247
|
+
return None
|
|
248
|
+
return data if isinstance(data, dict) else None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def parse_criterion_verdict(raw_text: str, rubric: Rubric) -> CriterionVerdict:
|
|
252
|
+
data = _extract_json(raw_text)
|
|
253
|
+
met = data.get("met") if isinstance(data, dict) else None
|
|
254
|
+
# Missing, null, or non-scalar `met` = the judge didn't actually decide →
|
|
255
|
+
# invalid (excluded from scoring), never a silent not-met.
|
|
256
|
+
if data is None or "met" not in data or met is None or isinstance(met, (list, dict)):
|
|
257
|
+
return CriterionVerdict(rubric.criterion, rubric.points, None, None, "", "invalid")
|
|
258
|
+
if not isinstance(met, bool):
|
|
259
|
+
met = str(met).strip().lower() in {"true", "yes", "1"}
|
|
260
|
+
conf = data.get("confidence")
|
|
261
|
+
try:
|
|
262
|
+
conf = max(0.0, min(1.0, float(conf))) if conf is not None else None
|
|
263
|
+
except (TypeError, ValueError):
|
|
264
|
+
conf = None
|
|
265
|
+
evidence = data.get("evidence")
|
|
266
|
+
evidence = evidence[:200] if isinstance(evidence, str) else ""
|
|
267
|
+
return CriterionVerdict(rubric.criterion, rubric.points, met, conf, evidence, "graded")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def score_case(verdicts: Iterable[CriterionVerdict]) -> float | None:
|
|
271
|
+
"""HealthBench-style normalized score: awarded points / possible positive points.
|
|
272
|
+
|
|
273
|
+
A positive-point criterion earns its points when met; a negative-point
|
|
274
|
+
criterion (a thing that should NOT appear) subtracts its points when met.
|
|
275
|
+
Score is clamped to 0..1 over the positive points actually graded. Returns
|
|
276
|
+
None when nothing was graded (so ungraded cases don't dilute the mean).
|
|
277
|
+
"""
|
|
278
|
+
graded = [v for v in verdicts if v.met is not None]
|
|
279
|
+
if not graded:
|
|
280
|
+
return None
|
|
281
|
+
max_positive = sum(v.points for v in graded if v.points > 0)
|
|
282
|
+
if max_positive <= 0:
|
|
283
|
+
return None
|
|
284
|
+
awarded = sum(v.points for v in graded if v.met)
|
|
285
|
+
return max(0.0, min(1.0, awarded / max_positive))
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def grade_dataset(
|
|
289
|
+
records: Iterable[dict],
|
|
290
|
+
judge: Judge,
|
|
291
|
+
*,
|
|
292
|
+
max_calls: int | None = None,
|
|
293
|
+
question_in_prompt: bool = True,
|
|
294
|
+
on_call: Callable[[int], None] | None = None,
|
|
295
|
+
) -> GradeReport:
|
|
296
|
+
"""Grade every gradeable case, one judge call per criterion, budget-capped.
|
|
297
|
+
|
|
298
|
+
``max_calls`` caps total judge calls across all cases; once hit, remaining
|
|
299
|
+
criteria are recorded as ``budget_blocked`` (not graded) rather than skipped
|
|
300
|
+
silently, so the denominator stays honest.
|
|
301
|
+
"""
|
|
302
|
+
report = GradeReport()
|
|
303
|
+
for case in extract_cases(records):
|
|
304
|
+
verdicts: list[CriterionVerdict] = []
|
|
305
|
+
for rubric in case.rubrics:
|
|
306
|
+
if max_calls is not None and report.calls >= max_calls:
|
|
307
|
+
verdicts.append(
|
|
308
|
+
CriterionVerdict(
|
|
309
|
+
rubric.criterion, rubric.points, None, None, "", "budget_blocked"
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
report.budget_blocked += 1
|
|
313
|
+
continue
|
|
314
|
+
prompt = build_criterion_prompt(
|
|
315
|
+
case.response, rubric, question=case.question if question_in_prompt else ""
|
|
316
|
+
)
|
|
317
|
+
raw = judge.evaluate(prompt)
|
|
318
|
+
report.calls += 1
|
|
319
|
+
if on_call is not None:
|
|
320
|
+
on_call(report.calls)
|
|
321
|
+
verdict = parse_criterion_verdict(
|
|
322
|
+
raw.get("notes", "") if isinstance(raw, dict) else "", rubric
|
|
323
|
+
)
|
|
324
|
+
if verdict.status == "invalid":
|
|
325
|
+
report.invalid += 1
|
|
326
|
+
verdicts.append(verdict)
|
|
327
|
+
report.cases.append(CaseGrade(case.case_id, verdicts, score_case(verdicts)))
|
|
328
|
+
return report
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# --- agreement: prove the cheap judge before trusting it -------------------
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@dataclass
|
|
335
|
+
class AgreementReport:
|
|
336
|
+
n: int # criteria graded by BOTH judges
|
|
337
|
+
agree: int # both said the same met/not-met
|
|
338
|
+
agreement: float | None # simple agreement rate
|
|
339
|
+
kappa: float | None # Cohen's kappa (chance-corrected)
|
|
340
|
+
calls_a: int
|
|
341
|
+
calls_b: int
|
|
342
|
+
|
|
343
|
+
def to_dict(self) -> dict:
|
|
344
|
+
return {
|
|
345
|
+
"n": self.n,
|
|
346
|
+
"agree": self.agree,
|
|
347
|
+
"agreement": round(self.agreement, 4) if self.agreement is not None else None,
|
|
348
|
+
"cohens_kappa": round(self.kappa, 4) if self.kappa is not None else None,
|
|
349
|
+
"calls_a": self.calls_a,
|
|
350
|
+
"calls_b": self.calls_b,
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _cohens_kappa(a: list[bool], b: list[bool]) -> float | None:
|
|
355
|
+
"""Chance-corrected agreement for two binary raters. None if undefined."""
|
|
356
|
+
n = len(a)
|
|
357
|
+
if n == 0:
|
|
358
|
+
return None
|
|
359
|
+
po = sum(1 for x, y in zip(a, b, strict=True) if x == y) / n
|
|
360
|
+
pa_true = sum(a) / n
|
|
361
|
+
pb_true = sum(b) / n
|
|
362
|
+
pe = pa_true * pb_true + (1 - pa_true) * (1 - pb_true)
|
|
363
|
+
if pe >= 1.0: # both raters constant and identical → perfect by convention
|
|
364
|
+
return 1.0 if po >= 1.0 else 0.0
|
|
365
|
+
return (po - pe) / (1 - pe)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def measure_agreement(
|
|
369
|
+
records: Iterable[dict],
|
|
370
|
+
judge_a: Judge,
|
|
371
|
+
judge_b: Judge,
|
|
372
|
+
*,
|
|
373
|
+
max_calls: int | None = None,
|
|
374
|
+
) -> AgreementReport:
|
|
375
|
+
"""Grade the same cases with two judges and report their per-criterion agreement.
|
|
376
|
+
|
|
377
|
+
Runs the full grading pass ONCE PER JUDGE, so this costs roughly 2x a single
|
|
378
|
+
grade (each judge's spend is reported separately as calls_a / calls_b).
|
|
379
|
+
``max_calls`` caps EACH judge's calls independently. Only criteria both judges
|
|
380
|
+
actually graded (met is not None) count toward agreement.
|
|
381
|
+
"""
|
|
382
|
+
records = list(records)
|
|
383
|
+
a = grade_dataset(records, judge_a, max_calls=max_calls)
|
|
384
|
+
b = grade_dataset(records, judge_b, max_calls=max_calls)
|
|
385
|
+
|
|
386
|
+
a_by_case = {c.case_id: c for c in a.cases}
|
|
387
|
+
va: list[bool] = []
|
|
388
|
+
vb: list[bool] = []
|
|
389
|
+
for cb in b.cases:
|
|
390
|
+
ca = a_by_case.get(cb.case_id)
|
|
391
|
+
if ca is None:
|
|
392
|
+
continue
|
|
393
|
+
by_crit = {v.criterion: v for v in ca.verdicts}
|
|
394
|
+
for v in cb.verdicts:
|
|
395
|
+
other = by_crit.get(v.criterion)
|
|
396
|
+
if other is not None and v.met is not None and other.met is not None:
|
|
397
|
+
va.append(other.met)
|
|
398
|
+
vb.append(v.met)
|
|
399
|
+
n = len(va)
|
|
400
|
+
agree = sum(1 for x, y in zip(va, vb, strict=True) if x == y)
|
|
401
|
+
return AgreementReport(
|
|
402
|
+
n=n,
|
|
403
|
+
agree=agree,
|
|
404
|
+
agreement=(agree / n) if n else None,
|
|
405
|
+
kappa=_cohens_kappa(va, vb),
|
|
406
|
+
calls_a=a.calls,
|
|
407
|
+
calls_b=b.calls,
|
|
408
|
+
)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""CLI for decomposed rubric grading (`alignmenter rubric-grade`).
|
|
2
|
+
|
|
3
|
+
Grades a captured dataset against its per-record ``metadata.rubrics`` — one narrow
|
|
4
|
+
judge call per criterion — with a cheap gateway model, budget-capped. With
|
|
5
|
+
``--compare-judge`` it runs an agreement check (cheap vs strong) instead, so the
|
|
6
|
+
cheap judge is proven before it is trusted. The judge model is any Vercel AI
|
|
7
|
+
Gateway ``provider/model`` string; auth is ``AI_GATEWAY_API_KEY`` (or ``OPENAI_API_KEY``).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from alignmenter.rubric_grade import grade_dataset, measure_agreement
|
|
19
|
+
from alignmenter.utils.io import read_jsonl, write_json
|
|
20
|
+
|
|
21
|
+
GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v1"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _make_gateway_judge(model: str, base_url: str):
|
|
25
|
+
"""An OpenAIJudge whose OpenAI-compatible client points at the AI Gateway.
|
|
26
|
+
|
|
27
|
+
The gateway routes by the ``provider/model`` string, so any gateway model
|
|
28
|
+
works as a judge without a provider-specific SDK. Kept lazy so importing the
|
|
29
|
+
CLI never requires the openai package.
|
|
30
|
+
"""
|
|
31
|
+
from openai import OpenAI
|
|
32
|
+
|
|
33
|
+
from alignmenter.providers.judges import OpenAIJudge
|
|
34
|
+
|
|
35
|
+
api_key = os.environ.get("AI_GATEWAY_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
|
36
|
+
if not api_key:
|
|
37
|
+
raise typer.BadParameter(
|
|
38
|
+
"rubric-grade needs AI_GATEWAY_API_KEY (or OPENAI_API_KEY) for the judge model"
|
|
39
|
+
)
|
|
40
|
+
return OpenAIJudge(model=model, client=OpenAI(base_url=base_url, api_key=api_key))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def register_rubric_grade_command(app: typer.Typer) -> None:
|
|
44
|
+
@app.command("rubric-grade")
|
|
45
|
+
def rubric_grade_cmd(
|
|
46
|
+
dataset: Path = typer.Argument(
|
|
47
|
+
...,
|
|
48
|
+
exists=True,
|
|
49
|
+
dir_okay=False,
|
|
50
|
+
help="Captured dataset JSONL (turns + metadata.rubrics).",
|
|
51
|
+
),
|
|
52
|
+
judge: str = typer.Option(
|
|
53
|
+
..., "--judge", help="Gateway judge model, e.g. anthropic/claude-haiku-4.5."
|
|
54
|
+
),
|
|
55
|
+
compare_judge: str | None = typer.Option(
|
|
56
|
+
None, "--compare-judge", help="Second model → agreement mode (cheap vs strong)."
|
|
57
|
+
),
|
|
58
|
+
max_calls: int | None = typer.Option(
|
|
59
|
+
None, "--max-calls", min=1, help="Cap total judge calls (budget)."
|
|
60
|
+
),
|
|
61
|
+
base_url: str = typer.Option(GATEWAY_BASE_URL, "--base-url", help="AI Gateway base URL."),
|
|
62
|
+
out: Path | None = typer.Option(None, "--out", help="Write the full report JSON here."),
|
|
63
|
+
):
|
|
64
|
+
"""Grade captured responses against per-record rubrics with a cheap decomposed judge.
|
|
65
|
+
|
|
66
|
+
Grade mode (default):
|
|
67
|
+
alignmenter rubric-grade captures.jsonl --judge anthropic/claude-haiku-4.5 --max-calls 200
|
|
68
|
+
Agreement mode (prove the cheap judge against a strong one):
|
|
69
|
+
alignmenter rubric-grade captures.jsonl --judge anthropic/claude-haiku-4.5 \\
|
|
70
|
+
--compare-judge anthropic/claude-sonnet-5 --max-calls 200
|
|
71
|
+
"""
|
|
72
|
+
records = read_jsonl(dataset)
|
|
73
|
+
judge_a = _make_gateway_judge(judge, base_url)
|
|
74
|
+
|
|
75
|
+
if compare_judge:
|
|
76
|
+
judge_b = _make_gateway_judge(compare_judge, base_url)
|
|
77
|
+
report = measure_agreement(records, judge_a, judge_b, max_calls=max_calls)
|
|
78
|
+
payload = {
|
|
79
|
+
"mode": "agreement",
|
|
80
|
+
"judge_a": judge,
|
|
81
|
+
"judge_b": compare_judge,
|
|
82
|
+
**report.to_dict(),
|
|
83
|
+
}
|
|
84
|
+
agr = f"{report.agreement:.1%}" if report.agreement is not None else "n/a"
|
|
85
|
+
kappa = f"{report.kappa:.3f}" if report.kappa is not None else "n/a"
|
|
86
|
+
typer.echo(
|
|
87
|
+
f"agreement: {report.agree}/{report.n} criteria = {agr} · Cohen's κ={kappa} "
|
|
88
|
+
f"· calls {report.calls_a}+{report.calls_b}"
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
report = grade_dataset(records, judge_a, max_calls=max_calls)
|
|
92
|
+
payload = {"mode": "grade", "judge": judge, **report.to_dict()}
|
|
93
|
+
mean = f"{report.mean_score:.3f}" if report.mean_score is not None else "n/a"
|
|
94
|
+
typer.echo(
|
|
95
|
+
f"graded {len(report.graded_cases)}/{len(report.cases)} cases · mean score {mean} "
|
|
96
|
+
f"· {report.calls} calls"
|
|
97
|
+
+ (f" · {report.budget_blocked} budget-blocked" if report.budget_blocked else "")
|
|
98
|
+
+ (f" · {report.invalid} invalid" if report.invalid else "")
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if out is not None:
|
|
102
|
+
write_json(out, payload)
|
|
103
|
+
typer.echo(f"wrote report -> {out}")
|
|
104
|
+
else:
|
|
105
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
@@ -18,6 +18,8 @@ src/alignmenter/cli.py
|
|
|
18
18
|
src/alignmenter/config.py
|
|
19
19
|
src/alignmenter/dataset_cli.py
|
|
20
20
|
src/alignmenter/release_cli.py
|
|
21
|
+
src/alignmenter/rubric_grade.py
|
|
22
|
+
src/alignmenter/rubric_grade_cli.py
|
|
21
23
|
src/alignmenter/run_config.py
|
|
22
24
|
src/alignmenter/runner.py
|
|
23
25
|
src/alignmenter/sdk.py
|
|
@@ -142,6 +144,7 @@ tests/test_provider_openai.py
|
|
|
142
144
|
tests/test_providers.py
|
|
143
145
|
tests/test_release_workflow.py
|
|
144
146
|
tests/test_review_workflow.py
|
|
147
|
+
tests/test_rubric_grade.py
|
|
145
148
|
tests/test_run_config_grounded.py
|
|
146
149
|
tests/test_run_config_loader.py
|
|
147
150
|
tests/test_run_openai_demo.py
|