alignmenter 0.3.0__tar.gz → 0.3.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.
- {alignmenter-0.3.0/src/alignmenter.egg-info → alignmenter-0.3.2}/PKG-INFO +1 -1
- {alignmenter-0.3.0 → alignmenter-0.3.2}/datasets/README.md +22 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/_version.py +1 -1
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/cli.py +3 -0
- alignmenter-0.3.2/src/alignmenter/dataset_cli.py +205 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/release_cli.py +22 -2
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/reporting/durable.py +3 -1
- alignmenter-0.3.2/src/alignmenter/reporting/github_comment.py +140 -0
- alignmenter-0.3.2/src/alignmenter/schemas/dataset.py +139 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2/src/alignmenter.egg-info}/PKG-INFO +1 -1
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter.egg-info/SOURCES.txt +5 -0
- alignmenter-0.3.2/tests/test_dataset_management.py +142 -0
- alignmenter-0.3.2/tests/test_github_comment.py +116 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/LICENSE +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/MANIFEST.in +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/README.md +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/configs/demo_config.yaml +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/configs/judges/safety_prompt.txt +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/configs/persona/default.yaml +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/configs/run-grounded.yaml +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/configs/run.yaml +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/configs/safety_keywords.yaml +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/datasets/demo_conversations.jsonl +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/datasets/grounded_demo.jsonl +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/datasets/wendys_twitter.jsonl +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/pyproject.toml +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/setup.cfg +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/__init__.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/calibration/__init__.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/calibration/analyze.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/calibration/bounds.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/calibration/diagnose.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/calibration/generate.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/calibration/label.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/calibration/optimize.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/calibration/sampling.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/calibration/validate.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/config.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/data/configs/demo_config.yaml +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/data/configs/judges/safety_prompt.txt +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/data/configs/persona/default.yaml +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/data/configs/run-grounded.yaml +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/data/configs/run.yaml +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/data/configs/safety_keywords.yaml +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/data/datasets/demo_conversations.jsonl +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/data/datasets/grounded_demo.jsonl +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/evaluators/__init__.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/evaluators/custom.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/evaluators/evidence.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/evaluators/faithfulness.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/evaluators/grounding.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/evaluators/metrics.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/examples/__init__.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/examples/resource_task.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/execution/__init__.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/execution/archive.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/execution/artifacts.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/execution/comparison.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/execution/evaluation.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/execution/gates.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/execution/leases.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/execution/legacy.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/execution/recovery.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/execution/review.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/execution/suite.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/judges/__init__.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/judges/authenticity_judge.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/judges/prompts.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/providers/__init__.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/providers/anthropic.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/providers/base.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/providers/callable.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/providers/classifiers.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/providers/durable_judge.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/providers/embeddings.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/providers/judges.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/providers/local.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/providers/openai.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/reporting/__init__.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/reporting/html.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/reporting/json_out.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/run_config.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/runner.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/schemas/__init__.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/schemas/evaluation.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/schemas/execution.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/schemas/gates.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/schemas/metrics.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/schemas/review.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/schemas/scoring.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/schemas/suite.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/scorers/__init__.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/scorers/authenticity.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/scorers/faithfulness.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/scorers/grounding.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/scorers/safety.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/scorers/stability.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/scripts/__init__.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/scripts/bootstrap_dataset.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/scripts/calibrate_persona.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/scripts/run_openai_demo.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/scripts/sanitize_dataset.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/sdk.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/storage/__init__.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/storage/evaluations.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/storage/reviews.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/storage/runs.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/utils/__init__.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/utils/io.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/utils/optional.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/utils/tokens.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/utils/yaml.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter.egg-info/dependency_links.txt +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter.egg-info/entry_points.txt +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter.egg-info/requires.txt +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter.egg-info/top_level.txt +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/__init__.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/conftest.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/data/durable_evaluation_judge.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/data/durable_evaluation_worker.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/data/durable_recovery_target.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/data/durable_recovery_worker.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/data/durable_run_worker.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/data/mini_cli_dataset.jsonl +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_authenticity_judge.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_builtin_evaluations.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_calibrate_persona.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_capture_recovery.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_cli_errors.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_cli_grounded.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_cli_helpers.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_cli_import.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_cli_init.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_cli_run_config.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_config.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_durable_evaluations.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_durable_execution.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_faithfulness.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_grounding.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_html_report.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_judge_providers.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_offline_safety.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_persona_gpt.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_provider_local.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_provider_openai.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_providers.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_release_workflow.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_review_workflow.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_run_config_grounded.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_run_config_loader.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_run_openai_demo.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_runner.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_sampling.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_scorers.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_smoke.py +0 -0
- {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_suite_archive.py +0 -0
|
@@ -11,6 +11,28 @@ Alignmenter datasets serve two primary purposes:
|
|
|
11
11
|
|
|
12
12
|
All datasets in this repository follow strict data hygiene practices to protect user privacy and enable reproducible research.
|
|
13
13
|
|
|
14
|
+
## Dataset management commands
|
|
15
|
+
|
|
16
|
+
The `alignmenter dataset` sub-app manages datasets as content-addressed, versioned
|
|
17
|
+
artifacts:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
alignmenter dataset stats data.jsonl [--json] # coverage: counts, roles, tag/persona histograms
|
|
21
|
+
alignmenter dataset validate data.jsonl [--strict] # canonical schema check (exit 1 on errors)
|
|
22
|
+
alignmenter dataset dedupe data.jsonl --out out.jsonl # drop content-identical records
|
|
23
|
+
alignmenter dataset merge a.jsonl b.jsonl --out m.jsonl [--dedupe] [--namespace-sessions]
|
|
24
|
+
alignmenter dataset split data.jsonl --out dir/ --holdout 0.2 --by split_group # group-aware train/holdout
|
|
25
|
+
alignmenter dataset manifest data.jsonl --out manifest.json # content-addressed manifest + provenance
|
|
26
|
+
alignmenter dataset manifest data.jsonl --verify manifest.json # confirm the data still matches (exit 2 on drift)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`validate` checks the row schema below (lenient by default, `--strict` for the fuller
|
|
30
|
+
contract); `lint` additionally checks turn sequencing, scenario-tag coverage, and
|
|
31
|
+
persona files. `split` keeps a
|
|
32
|
+
case and its counterfactual variants on the same side of the boundary via the
|
|
33
|
+
grouping unit (`split_group` / `group:` tag / session / persona). `manifest`'s digest
|
|
34
|
+
is order-independent (reordering rows does not change the dataset's identity).
|
|
35
|
+
|
|
14
36
|
## Dataset Format
|
|
15
37
|
|
|
16
38
|
### Conversation JSONL Schema
|
|
@@ -21,6 +21,7 @@ import typer
|
|
|
21
21
|
import yaml
|
|
22
22
|
|
|
23
23
|
from alignmenter.config import get_settings
|
|
24
|
+
from alignmenter.dataset_cli import register_dataset_commands
|
|
24
25
|
from alignmenter.evaluators.custom import evaluator_registry, load_evaluators
|
|
25
26
|
from alignmenter.execution.evaluation import evaluate_saved, evaluation_summary
|
|
26
27
|
from alignmenter.execution.recovery import resume_capture
|
|
@@ -58,6 +59,8 @@ app.add_typer(dataset_app, name="dataset")
|
|
|
58
59
|
app.add_typer(import_app, name="import")
|
|
59
60
|
app.add_typer(calibrate_app, name="calibrate")
|
|
60
61
|
|
|
62
|
+
register_dataset_commands(dataset_app)
|
|
63
|
+
|
|
61
64
|
|
|
62
65
|
def _show_version(value):
|
|
63
66
|
if value:
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Dataset-management commands: stats, validate, dedupe, merge, split, manifest.
|
|
2
|
+
|
|
3
|
+
Registered onto the existing `dataset` sub-app (alongside lint/sanitize/bootstrap)
|
|
4
|
+
via register_dataset_commands(dataset_app). Builds on the shared primitives
|
|
5
|
+
(content_digest, read/write_jsonl) and schemas/dataset.py.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import random
|
|
12
|
+
from collections import Counter, defaultdict
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from alignmenter.schemas.dataset import (
|
|
18
|
+
ProvenanceEntry,
|
|
19
|
+
build_manifest,
|
|
20
|
+
dataset_digest,
|
|
21
|
+
validate_records,
|
|
22
|
+
)
|
|
23
|
+
from alignmenter.schemas.execution import content_digest
|
|
24
|
+
from alignmenter.utils.io import read_jsonl, write_json, write_jsonl
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _group_key(record: dict, by: str) -> str:
|
|
28
|
+
"""Grouping key for split — keeps a case + its variants on the same side."""
|
|
29
|
+
if not isinstance(record, dict):
|
|
30
|
+
return "" # non-dict rows group together (like the other commands, don't crash)
|
|
31
|
+
metadata = record.get("metadata") or {}
|
|
32
|
+
session = record.get("session_id") or ""
|
|
33
|
+
if by == "split_group":
|
|
34
|
+
return str(metadata.get("split_group") or session)
|
|
35
|
+
if by == "group":
|
|
36
|
+
for tag in record.get("tags") or []:
|
|
37
|
+
if isinstance(tag, str) and tag.startswith("group:"):
|
|
38
|
+
return tag
|
|
39
|
+
return session
|
|
40
|
+
if by == "persona":
|
|
41
|
+
return str(record.get("persona_id") or session)
|
|
42
|
+
return session
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def register_dataset_commands(dataset_app: typer.Typer) -> None:
|
|
46
|
+
@dataset_app.command("stats")
|
|
47
|
+
def stats(
|
|
48
|
+
path: Path = typer.Argument(..., exists=True, dir_okay=False),
|
|
49
|
+
as_json: bool = typer.Option(False, "--json", help="Emit the coverage report as JSON."),
|
|
50
|
+
):
|
|
51
|
+
"""Coverage report — record/session counts, role balance, tag + persona histograms."""
|
|
52
|
+
records = read_jsonl(path)
|
|
53
|
+
manifest = build_manifest(records, id=path.stem, revision="stats")
|
|
54
|
+
roles = Counter(r.get("role") for r in records if isinstance(r, dict))
|
|
55
|
+
with_context = sum(
|
|
56
|
+
1 for r in records
|
|
57
|
+
if isinstance(r, dict) and isinstance(r.get("metadata"), dict) and "context" in r["metadata"]
|
|
58
|
+
)
|
|
59
|
+
if as_json:
|
|
60
|
+
typer.echo(json.dumps({
|
|
61
|
+
"records": manifest.record_count, "sessions": manifest.session_count,
|
|
62
|
+
"roles": {k: v for k, v in roles.items()}, "with_context": with_context,
|
|
63
|
+
"tags": manifest.tags, "personas": manifest.personas,
|
|
64
|
+
"content_digest": manifest.content_digest,
|
|
65
|
+
}, indent=2))
|
|
66
|
+
return
|
|
67
|
+
typer.echo(f"records {manifest.record_count} · sessions {manifest.session_count} · turns-with-context {with_context}")
|
|
68
|
+
typer.echo("roles: " + ", ".join(f"{k}={v}" for k, v in sorted(roles.items(), key=lambda kv: str(kv[0]))))
|
|
69
|
+
if manifest.tags:
|
|
70
|
+
typer.echo("tags: " + ", ".join(f"{k}={v}" for k, v in sorted(manifest.tags.items())))
|
|
71
|
+
if manifest.personas:
|
|
72
|
+
typer.echo("personas: " + ", ".join(f"{k}={v}" for k, v in sorted(manifest.personas.items())))
|
|
73
|
+
typer.echo(f"content_digest: {manifest.content_digest[:12]}…")
|
|
74
|
+
|
|
75
|
+
@dataset_app.command("validate")
|
|
76
|
+
def validate(
|
|
77
|
+
path: Path = typer.Argument(..., exists=True, dir_okay=False),
|
|
78
|
+
strict: bool = typer.Option(False, "--strict", help="Require turn_index+tags+persona_id and an assistant turn per session."),
|
|
79
|
+
):
|
|
80
|
+
"""Schema validator — lenient by default, --strict adds turn_index+tags+persona_id + an assistant turn per session.
|
|
81
|
+
|
|
82
|
+
Complements `dataset lint`, which additionally checks turn-index contiguity,
|
|
83
|
+
scenario-tag coverage, and persona-file existence.
|
|
84
|
+
"""
|
|
85
|
+
records = read_jsonl(path)
|
|
86
|
+
errors = validate_records(records, strict=strict)
|
|
87
|
+
for error in errors:
|
|
88
|
+
typer.echo(error, err=True)
|
|
89
|
+
typer.echo(f"{len(records)} records, {len(errors)} error(s)")
|
|
90
|
+
raise typer.Exit(1 if errors else 0)
|
|
91
|
+
|
|
92
|
+
@dataset_app.command("dedupe")
|
|
93
|
+
def dedupe(
|
|
94
|
+
path: Path = typer.Argument(..., exists=True, dir_okay=False),
|
|
95
|
+
out: Path | None = typer.Option(None, "--out", help="Output path (default: <stem>.dedup.jsonl)."),
|
|
96
|
+
in_place: bool = typer.Option(False, "--in-place"),
|
|
97
|
+
):
|
|
98
|
+
"""Drop content-identical duplicate records (order preserved)."""
|
|
99
|
+
records = read_jsonl(path)
|
|
100
|
+
seen: set[str] = set()
|
|
101
|
+
kept = []
|
|
102
|
+
for record in records:
|
|
103
|
+
digest = content_digest(record)
|
|
104
|
+
if digest in seen:
|
|
105
|
+
continue
|
|
106
|
+
seen.add(digest)
|
|
107
|
+
kept.append(record)
|
|
108
|
+
destination = path if in_place else (out or path.with_name(f"{path.stem}.dedup.jsonl"))
|
|
109
|
+
write_jsonl(destination, kept)
|
|
110
|
+
typer.echo(f"removed {len(records) - len(kept)} duplicate(s); wrote {len(kept)} -> {destination}")
|
|
111
|
+
|
|
112
|
+
@dataset_app.command("merge")
|
|
113
|
+
def merge(
|
|
114
|
+
paths: list[Path] = typer.Argument(..., exists=True, dir_okay=False),
|
|
115
|
+
out: Path = typer.Option(..., "--out"),
|
|
116
|
+
dedupe: bool = typer.Option(False, "--dedupe", help="Drop content-identical records after merging."),
|
|
117
|
+
namespace_sessions: bool = typer.Option(False, "--namespace-sessions", help="Prefix session_id with the source file stem to avoid collisions."),
|
|
118
|
+
):
|
|
119
|
+
"""Concatenate datasets into one (optionally dedupe / namespace session ids)."""
|
|
120
|
+
merged: list[dict] = []
|
|
121
|
+
for source in paths:
|
|
122
|
+
records = read_jsonl(source)
|
|
123
|
+
if namespace_sessions:
|
|
124
|
+
for record in records:
|
|
125
|
+
if isinstance(record, dict) and record.get("session_id"):
|
|
126
|
+
record["session_id"] = f"{source.stem}:{record['session_id']}"
|
|
127
|
+
merged.extend(records)
|
|
128
|
+
if dedupe:
|
|
129
|
+
seen: set[str] = set()
|
|
130
|
+
deduped = []
|
|
131
|
+
for record in merged:
|
|
132
|
+
digest = content_digest(record)
|
|
133
|
+
if digest in seen:
|
|
134
|
+
continue
|
|
135
|
+
seen.add(digest)
|
|
136
|
+
deduped.append(record)
|
|
137
|
+
merged = deduped
|
|
138
|
+
write_jsonl(out, merged)
|
|
139
|
+
typer.echo(f"merged {len(paths)} file(s) -> {len(merged)} records -> {out}")
|
|
140
|
+
|
|
141
|
+
@dataset_app.command("split")
|
|
142
|
+
def split(
|
|
143
|
+
path: Path = typer.Argument(..., exists=True, dir_okay=False),
|
|
144
|
+
out: Path = typer.Option(..., "--out", help="Output directory for train.jsonl + holdout.jsonl."),
|
|
145
|
+
holdout: float = typer.Option(0.2, "--holdout", min=0.0, max=1.0, help="Target holdout fraction of records."),
|
|
146
|
+
by: str = typer.Option("split_group", "--by", help="Grouping unit kept together: split_group | group | session | persona."),
|
|
147
|
+
seed: int = typer.Option(42, "--seed"),
|
|
148
|
+
):
|
|
149
|
+
"""Group-aware train/holdout split — a case and its variants never straddle the boundary."""
|
|
150
|
+
if by not in {"split_group", "group", "session", "persona"}:
|
|
151
|
+
raise typer.BadParameter("--by must be split_group, group, session, or persona")
|
|
152
|
+
records = read_jsonl(path)
|
|
153
|
+
groups: dict[str, list[dict]] = defaultdict(list)
|
|
154
|
+
for record in records:
|
|
155
|
+
groups[_group_key(record, by)].append(record)
|
|
156
|
+
names = sorted(groups)
|
|
157
|
+
random.Random(seed).shuffle(names)
|
|
158
|
+
target = holdout * len(records)
|
|
159
|
+
held: set[str] = set()
|
|
160
|
+
accumulated = 0
|
|
161
|
+
for name in names:
|
|
162
|
+
if accumulated >= target:
|
|
163
|
+
break
|
|
164
|
+
held.add(name)
|
|
165
|
+
accumulated += len(groups[name])
|
|
166
|
+
train = [r for r in records if _group_key(r, by) not in held]
|
|
167
|
+
holdout_records = [r for r in records if _group_key(r, by) in held]
|
|
168
|
+
write_jsonl(Path(out) / "train.jsonl", train)
|
|
169
|
+
write_jsonl(Path(out) / "holdout.jsonl", holdout_records)
|
|
170
|
+
typer.echo(
|
|
171
|
+
f"train {len(train)} · holdout {len(holdout_records)} "
|
|
172
|
+
f"({len(held)}/{len(names)} '{by}' groups) -> {out}"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
@dataset_app.command("manifest")
|
|
176
|
+
def manifest(
|
|
177
|
+
path: Path = typer.Argument(..., exists=True, dir_okay=False),
|
|
178
|
+
out: Path | None = typer.Option(None, "--out", help="Write the manifest JSON here (default: stdout)."),
|
|
179
|
+
identifier: str | None = typer.Option(None, "--id", help="Dataset id (default: file stem)."),
|
|
180
|
+
revision: str = typer.Option("v1", "--revision"),
|
|
181
|
+
verify: Path | None = typer.Option(None, "--verify", help="Verify the data still matches this manifest's content_digest."),
|
|
182
|
+
):
|
|
183
|
+
"""Build (or --verify) a content-addressed dataset manifest with provenance."""
|
|
184
|
+
records = read_jsonl(path)
|
|
185
|
+
if verify is not None:
|
|
186
|
+
try:
|
|
187
|
+
expected = json.loads(Path(verify).read_text()).get("content_digest")
|
|
188
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
189
|
+
raise typer.BadParameter(f"Could not read manifest {verify}: {exc}") from exc
|
|
190
|
+
actual = dataset_digest(records)
|
|
191
|
+
match = expected == actual
|
|
192
|
+
typer.echo(f"{'OK' if match else 'MISMATCH'}: manifest {str(expected)[:12]} vs data {actual[:12]}")
|
|
193
|
+
raise typer.Exit(0 if match else 2)
|
|
194
|
+
built = build_manifest(
|
|
195
|
+
records,
|
|
196
|
+
id=identifier or path.stem,
|
|
197
|
+
revision=revision,
|
|
198
|
+
provenance=(ProvenanceEntry(kind="authored", ref=str(path), count=len(records), digest=dataset_digest(records)),),
|
|
199
|
+
)
|
|
200
|
+
payload = built.model_dump(mode="json")
|
|
201
|
+
if out is not None:
|
|
202
|
+
write_json(out, payload)
|
|
203
|
+
typer.echo(f"wrote manifest -> {out}")
|
|
204
|
+
else:
|
|
205
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
@@ -22,6 +22,18 @@ from alignmenter.schemas.gates import GatePolicy
|
|
|
22
22
|
EXIT_CODES = {"pass": 0, "fail": 2, "inconclusive": 3}
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
def _exit_code(decision: str, *, allow_inconclusive: bool = False) -> int:
|
|
26
|
+
"""Map a decision to a process exit code.
|
|
27
|
+
|
|
28
|
+
With ``allow_inconclusive`` an inconclusive decision (e.g. an unreviewed
|
|
29
|
+
``draft`` spec that met every applicable criterion) exits 0 so an automated
|
|
30
|
+
gate is not blocked; a genuine ``fail`` still exits non-zero.
|
|
31
|
+
"""
|
|
32
|
+
if allow_inconclusive and decision == "inconclusive":
|
|
33
|
+
return 0
|
|
34
|
+
return EXIT_CODES[decision]
|
|
35
|
+
|
|
36
|
+
|
|
25
37
|
def register_release_commands(app):
|
|
26
38
|
@app.command("archive-export")
|
|
27
39
|
def archive_export(
|
|
@@ -67,6 +79,9 @@ def register_release_commands(app):
|
|
|
67
79
|
suite: Path = typer.Argument(..., exists=True, dir_okay=False),
|
|
68
80
|
out: Path = typer.Option(Path("reports"), "--out"),
|
|
69
81
|
resume: Path | None = typer.Option(None, "--resume", exists=True, file_okay=False),
|
|
82
|
+
allow_inconclusive: bool = typer.Option(
|
|
83
|
+
False, "--allow-inconclusive",
|
|
84
|
+
help="Exit 0 on an inconclusive decision (a fail still exits non-zero)."),
|
|
70
85
|
):
|
|
71
86
|
"""Capture, evaluate, compare, and write CI artifacts under a frozen suite config."""
|
|
72
87
|
try:
|
|
@@ -74,7 +89,9 @@ def register_release_commands(app):
|
|
|
74
89
|
except Exception as exc:
|
|
75
90
|
raise typer.BadParameter(str(exc)) from exc
|
|
76
91
|
typer.echo(json.dumps(result, indent=2))
|
|
77
|
-
|
|
92
|
+
if allow_inconclusive and result["decision"] == "inconclusive":
|
|
93
|
+
typer.echo("Inconclusive tolerated (--allow-inconclusive): exiting 0.")
|
|
94
|
+
raise typer.Exit(_exit_code(result["decision"], allow_inconclusive=allow_inconclusive))
|
|
78
95
|
|
|
79
96
|
@app.command("review-export")
|
|
80
97
|
def review_export(
|
|
@@ -137,6 +154,9 @@ def register_release_commands(app):
|
|
|
137
154
|
baseline: Path | None = typer.Option(None, "--baseline", exists=True, file_okay=False),
|
|
138
155
|
baseline_id: UUID | None = typer.Option(None, "--baseline-id"),
|
|
139
156
|
force: bool = typer.Option(False, "--force"),
|
|
157
|
+
allow_inconclusive: bool = typer.Option(
|
|
158
|
+
False, "--allow-inconclusive",
|
|
159
|
+
help="Exit 0 on an inconclusive decision (a fail still exits non-zero)."),
|
|
140
160
|
):
|
|
141
161
|
"""Check saved results and export CI artifacts without invoking any provider."""
|
|
142
162
|
try:
|
|
@@ -147,7 +167,7 @@ def register_release_commands(app):
|
|
|
147
167
|
raise typer.BadParameter(str(exc)) from exc
|
|
148
168
|
decision = report["gate_report"]["decision"]
|
|
149
169
|
typer.echo(f"Decision: {decision}\nArtifacts: {out.resolve()}")
|
|
150
|
-
raise typer.Exit(
|
|
170
|
+
raise typer.Exit(_exit_code(decision, allow_inconclusive=allow_inconclusive))
|
|
151
171
|
|
|
152
172
|
@app.command("compare")
|
|
153
173
|
def compare(
|
|
@@ -12,6 +12,7 @@ from xml.etree import ElementTree as ET
|
|
|
12
12
|
|
|
13
13
|
from alignmenter.execution.evaluation import evaluation_summary
|
|
14
14
|
from alignmenter.execution.gates import gate_report
|
|
15
|
+
from alignmenter.reporting.github_comment import render_github_comment
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
def _escape(value):
|
|
@@ -167,5 +168,6 @@ def export_evaluation(run_dir, out_dir, *, evaluation_id=None, policy=None, comp
|
|
|
167
168
|
report["comparison"] = comparison
|
|
168
169
|
report["review"] = qualification_report(run_dir, report["evaluation_id"])
|
|
169
170
|
write_artifacts(out_dir, {"evaluation.json": _pretty(report) + "\n", "index.html": render_html(report),
|
|
170
|
-
"junit.xml": render_junit(report), "summary.md": render_markdown(report)
|
|
171
|
+
"junit.xml": render_junit(report), "summary.md": render_markdown(report),
|
|
172
|
+
"comment.md": render_github_comment(report)}, force=force)
|
|
171
173
|
return report
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Sticky GitHub pull-request comment over one saved decision; never executes a scorer.
|
|
2
|
+
|
|
3
|
+
Renders the same ``report`` structure the other durable reporters consume
|
|
4
|
+
(:mod:`alignmenter.reporting.durable`) into Markdown tuned for a PR comment: a
|
|
5
|
+
verdict badge, blocking issues first, a gate table, metrics (with baseline
|
|
6
|
+
deltas when a comparison is present), and collapsed breakdowns. The leading
|
|
7
|
+
marker lets a CI step find and update one sticky comment instead of appending.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
MARKER = "<!-- alignmenter:report -->"
|
|
13
|
+
|
|
14
|
+
_DECISION_BADGE = {"pass": "✅ Pass", "fail": "❌ Fail", "inconclusive": "⚠️ Inconclusive"}
|
|
15
|
+
_CHECK_ICON = {"pass": "✅", "fail": "❌", "inconclusive": "➖"}
|
|
16
|
+
_OPERATOR = {"at_least": "≥", "at_most": "≤"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _cell(value):
|
|
20
|
+
"""Escape a value for a single Markdown table cell."""
|
|
21
|
+
if value is None:
|
|
22
|
+
return "—"
|
|
23
|
+
return (str(value).replace("\\", "\\\\").replace("|", "\\|")
|
|
24
|
+
.replace("\n", " ").replace("<", "<").replace(">", ">"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _num(value, digits=3):
|
|
28
|
+
if value is None:
|
|
29
|
+
return "—"
|
|
30
|
+
if isinstance(value, bool):
|
|
31
|
+
return str(value)
|
|
32
|
+
if isinstance(value, float):
|
|
33
|
+
return f"{value:.{digits}f}".rstrip("0").rstrip(".") or "0"
|
|
34
|
+
return str(value)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _signed(value, digits=3):
|
|
38
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
39
|
+
return ("+" if value > 0 else "") + _num(value, digits)
|
|
40
|
+
return _num(value, digits)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _icon(decision):
|
|
44
|
+
return _CHECK_ICON.get(decision, _cell(decision))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _gate_detail(check):
|
|
48
|
+
metric = check.get("metric")
|
|
49
|
+
if metric is None:
|
|
50
|
+
return _cell(check.get("reason", ""))
|
|
51
|
+
operator = _OPERATOR.get(check.get("operator"), check.get("operator") or "")
|
|
52
|
+
return f"`{_cell(metric)}` {operator} {_num(check.get('threshold'))} · got **{_num(check.get('value'))}**"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _blocking_lines(report):
|
|
56
|
+
lines = []
|
|
57
|
+
for check in report["gate_report"].get("checks", []):
|
|
58
|
+
# Skip the structural "evaluation"/"comparison" umbrella checks (no metric);
|
|
59
|
+
# the ❌ badge and the violated cases below already convey those.
|
|
60
|
+
if check["decision"] == "fail" and check.get("metric") is not None:
|
|
61
|
+
lines.append(f"- **{_cell(check['id'])}** — {_gate_detail(check)}")
|
|
62
|
+
inputs = {item["key"]: item for item in report.get("inputs", [])}
|
|
63
|
+
violated = [(inputs.get(r["input_key"]), r) for r in report.get("results", [])
|
|
64
|
+
if r.get("status") == "violated"]
|
|
65
|
+
for item, result in violated[:5]:
|
|
66
|
+
item = item or {}
|
|
67
|
+
label = _cell(item.get("case_id") or item.get("session_id") or "case")
|
|
68
|
+
lines.append(f"- case **{label}** / {_cell(item.get('criterion_id'))}: "
|
|
69
|
+
f"{_cell(result.get('reason') or 'violated')}")
|
|
70
|
+
if len(violated) > 5:
|
|
71
|
+
lines.append(f"- …and {len(violated) - 5} more violated case(s)")
|
|
72
|
+
return lines
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _gate_table(report):
|
|
76
|
+
checks = report["gate_report"].get("checks", [])
|
|
77
|
+
if not checks:
|
|
78
|
+
return []
|
|
79
|
+
rows = ["| Gate | Result | Detail |", "| --- | :---: | --- |"]
|
|
80
|
+
rows += [f"| {_cell(c['id'])} | {_icon(c['decision'])} | {_gate_detail(c)} |" for c in checks]
|
|
81
|
+
return ["#### Gates", *rows, ""]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _metrics_table(report):
|
|
85
|
+
comparison = report.get("comparison")
|
|
86
|
+
if comparison and comparison.get("metrics"):
|
|
87
|
+
rows = ["| Metric | Baseline | Candidate | Δ | Note |",
|
|
88
|
+
"| --- | ---: | ---: | ---: | :---: |"]
|
|
89
|
+
for name, metric in comparison["metrics"].items():
|
|
90
|
+
note = "⚠️ unavailable" if metric.get("unavailable") else ""
|
|
91
|
+
rows.append(f"| `{_cell(name)}` | {_num(metric.get('baseline'))} | "
|
|
92
|
+
f"{_num(metric.get('candidate'))} | {_signed(metric.get('delta'))} | {note} |")
|
|
93
|
+
return ["#### Metrics (vs baseline)", *rows, ""]
|
|
94
|
+
metrics = report.get("metrics") or {}
|
|
95
|
+
if not metrics:
|
|
96
|
+
return []
|
|
97
|
+
rows = ["| Metric | Value | Denominator |", "| --- | ---: | ---: |"]
|
|
98
|
+
rows += [f"| `{_cell(name)}` | {_num(m.get('value'))} | {_num(m.get('denominator'))} |"
|
|
99
|
+
for name, m in metrics.items()]
|
|
100
|
+
return ["#### Metrics", *rows, ""]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _details(summary, body):
|
|
104
|
+
return [f"<details><summary>{_cell(summary)}</summary>", "", body, "", "</details>", ""]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _breakdown(report):
|
|
108
|
+
lines = []
|
|
109
|
+
criteria = report.get("criteria") or {}
|
|
110
|
+
if criteria:
|
|
111
|
+
rows = ["| Criterion | met | violated | n/a | decision |",
|
|
112
|
+
"| --- | ---: | ---: | ---: | :---: |"]
|
|
113
|
+
for cid, summary in criteria.items():
|
|
114
|
+
counts = summary.get("counts", {})
|
|
115
|
+
rows.append(f"| `{_cell(cid)}` | {counts.get('met', 0)} | {counts.get('violated', 0)} | "
|
|
116
|
+
f"{counts.get('not_applicable', 0)} | {_icon(summary.get('decision'))} |")
|
|
117
|
+
lines += _details("Per-criterion breakdown", "\n".join(rows))
|
|
118
|
+
counts = report.get("counts") or {}
|
|
119
|
+
if counts:
|
|
120
|
+
lines += _details("Outcome counts", ", ".join(f"{k}: {v}" for k, v in counts.items()))
|
|
121
|
+
return lines
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def render_github_comment(report, *, title="Alignmenter"):
|
|
125
|
+
"""Return a sticky PR-comment Markdown body for one saved evaluation ``report``."""
|
|
126
|
+
decision = report["gate_report"]["decision"]
|
|
127
|
+
spec = report["spec"]
|
|
128
|
+
header = (f"`{_cell(spec['id'])} @ {_cell(spec['revision'])}` · "
|
|
129
|
+
f"qualification `{_cell(spec['qualification'])}` · "
|
|
130
|
+
f"{report['judged']}/{report['applicable']} applicable criteria evaluated")
|
|
131
|
+
if report.get("unavailable"):
|
|
132
|
+
header += f" · {report['unavailable']} unavailable"
|
|
133
|
+
lines = [MARKER, f"### {_DECISION_BADGE.get(decision, _cell(decision))} — {_cell(title)}", header, ""]
|
|
134
|
+
blocking = _blocking_lines(report)
|
|
135
|
+
if blocking:
|
|
136
|
+
lines += ["#### ❌ Blocking", *blocking, ""]
|
|
137
|
+
lines += _gate_table(report)
|
|
138
|
+
lines += _metrics_table(report)
|
|
139
|
+
lines += _breakdown(report)
|
|
140
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Dataset manifest + canonical record validation for dataset management.
|
|
2
|
+
|
|
3
|
+
Datasets are flat JSONL of turn records (see datasets/README.md). `validate_records`
|
|
4
|
+
is a schema validator: lenient by default (matching what capture requires —
|
|
5
|
+
session_id + role + text, unique turn_index per session), `strict=True` adds
|
|
6
|
+
turn_index + tags + persona_id and an assistant turn per session. It complements
|
|
7
|
+
`dataset lint`, which additionally checks turn-index contiguity, scenario-tag
|
|
8
|
+
coverage, and persona-file existence. `DatasetManifest` gives a dataset a
|
|
9
|
+
content-addressed identity + provenance so a curated set can be versioned and its
|
|
10
|
+
lineage tracked.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections import Counter, defaultdict
|
|
16
|
+
|
|
17
|
+
from pydantic import AwareDatetime, Field
|
|
18
|
+
|
|
19
|
+
from alignmenter.schemas.evaluation import NonBlank
|
|
20
|
+
from alignmenter.schemas.execution import (
|
|
21
|
+
Digest,
|
|
22
|
+
NonNegativeInt,
|
|
23
|
+
VersionedRecord,
|
|
24
|
+
content_digest,
|
|
25
|
+
utc_now,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
ROLES = {"user", "assistant"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def dataset_digest(records) -> str:
|
|
32
|
+
"""Order-independent content hash of a dataset (same records, any order → same digest).
|
|
33
|
+
|
|
34
|
+
content_digest sorts dict keys but preserves list order, so hashing the record
|
|
35
|
+
list directly would change when rows are merely reordered. Hashing the sorted
|
|
36
|
+
per-record digests gives a stable content identity for versioning/verification.
|
|
37
|
+
|
|
38
|
+
Note: this is distinct from RunManifest.dataset_digest (runner.py), which is the
|
|
39
|
+
order-SENSITIVE content_digest(records) used for run reproducibility — don't
|
|
40
|
+
compare the two.
|
|
41
|
+
"""
|
|
42
|
+
return content_digest(sorted(content_digest(r) for r in records))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ProvenanceEntry(VersionedRecord):
|
|
46
|
+
kind: NonBlank # authored | bootstrap | import | merge | dedupe | split | promote
|
|
47
|
+
ref: str = "" # source path / url / description
|
|
48
|
+
count: NonNegativeInt = 0
|
|
49
|
+
digest: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class DatasetManifest(VersionedRecord):
|
|
53
|
+
id: NonBlank
|
|
54
|
+
revision: NonBlank
|
|
55
|
+
content_digest: Digest
|
|
56
|
+
record_count: NonNegativeInt
|
|
57
|
+
session_count: NonNegativeInt
|
|
58
|
+
created_at: AwareDatetime = Field(default_factory=utc_now)
|
|
59
|
+
tags: dict[str, int] = Field(default_factory=dict)
|
|
60
|
+
personas: dict[str, int] = Field(default_factory=dict)
|
|
61
|
+
provenance: tuple[ProvenanceEntry, ...] = ()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_manifest(records, *, id: str, revision: str, provenance=()) -> DatasetManifest:
|
|
65
|
+
"""Content-address a record list into a DatasetManifest (order-independent digest)."""
|
|
66
|
+
dicts = [r for r in records if isinstance(r, dict)]
|
|
67
|
+
sessions = {r.get("session_id") for r in dicts if r.get("session_id")}
|
|
68
|
+
tags = Counter(t for r in dicts for t in (r.get("tags") or []) if isinstance(t, str))
|
|
69
|
+
personas = Counter(r["persona_id"] for r in dicts if isinstance(r.get("persona_id"), str))
|
|
70
|
+
return DatasetManifest(
|
|
71
|
+
id=id,
|
|
72
|
+
revision=revision,
|
|
73
|
+
content_digest=dataset_digest(records),
|
|
74
|
+
record_count=len(records),
|
|
75
|
+
session_count=len(sessions),
|
|
76
|
+
tags=dict(tags),
|
|
77
|
+
personas=dict(personas),
|
|
78
|
+
provenance=tuple(provenance),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def validate_records(records, *, strict: bool = False) -> list[str]:
|
|
83
|
+
"""Return a list of human-readable schema errors (empty == valid).
|
|
84
|
+
|
|
85
|
+
Default rules match ingest (session_id + role + text; turn_index optional but,
|
|
86
|
+
when present, a non-negative int unique within its session). ``strict`` adds the
|
|
87
|
+
lint contract: turn_index + tags + persona_id required, and every session must
|
|
88
|
+
have an assistant turn.
|
|
89
|
+
"""
|
|
90
|
+
errors: list[str] = []
|
|
91
|
+
seen_turns: dict[str, set[int]] = defaultdict(set)
|
|
92
|
+
session_roles: dict[str, set] = defaultdict(set)
|
|
93
|
+
|
|
94
|
+
for i, r in enumerate(records):
|
|
95
|
+
loc = f"record {i}"
|
|
96
|
+
if not isinstance(r, dict):
|
|
97
|
+
errors.append(f"{loc}: not a JSON object")
|
|
98
|
+
continue
|
|
99
|
+
sid = r.get("session_id")
|
|
100
|
+
if not isinstance(sid, str) or not sid.strip():
|
|
101
|
+
errors.append(f"{loc}: missing or blank session_id")
|
|
102
|
+
role = r.get("role")
|
|
103
|
+
if role not in ROLES:
|
|
104
|
+
errors.append(f"{loc}: role must be one of {sorted(ROLES)} (got {role!r})")
|
|
105
|
+
if not isinstance(r.get("text"), str):
|
|
106
|
+
errors.append(f"{loc}: text must be a string")
|
|
107
|
+
|
|
108
|
+
ti = r.get("turn_index")
|
|
109
|
+
if ti is not None:
|
|
110
|
+
if isinstance(ti, bool) or not isinstance(ti, int) or ti < 0:
|
|
111
|
+
errors.append(f"{loc}: turn_index must be a non-negative int")
|
|
112
|
+
elif isinstance(sid, str):
|
|
113
|
+
if ti in seen_turns[sid]:
|
|
114
|
+
errors.append(f"{loc}: duplicate turn_index {ti} in session {sid!r}")
|
|
115
|
+
seen_turns[sid].add(ti)
|
|
116
|
+
|
|
117
|
+
tags = r.get("tags")
|
|
118
|
+
if tags is not None and not (isinstance(tags, list) and all(isinstance(t, str) for t in tags)):
|
|
119
|
+
errors.append(f"{loc}: tags must be a list of strings")
|
|
120
|
+
md = r.get("metadata")
|
|
121
|
+
if md is not None and not isinstance(md, dict):
|
|
122
|
+
errors.append(f"{loc}: metadata must be an object")
|
|
123
|
+
|
|
124
|
+
if isinstance(sid, str) and role in ROLES:
|
|
125
|
+
session_roles[sid].add(role)
|
|
126
|
+
|
|
127
|
+
if strict:
|
|
128
|
+
if ti is None:
|
|
129
|
+
errors.append(f"{loc}: turn_index is required in --strict mode")
|
|
130
|
+
if not isinstance(tags, list):
|
|
131
|
+
errors.append(f"{loc}: tags is required in --strict mode")
|
|
132
|
+
if not isinstance(r.get("persona_id"), str):
|
|
133
|
+
errors.append(f"{loc}: persona_id is required in --strict mode")
|
|
134
|
+
|
|
135
|
+
if strict:
|
|
136
|
+
for sid, roles in session_roles.items():
|
|
137
|
+
if "assistant" not in roles:
|
|
138
|
+
errors.append(f"session {sid!r}: has no assistant turn (--strict)")
|
|
139
|
+
return errors
|
|
@@ -16,6 +16,7 @@ src/alignmenter/__init__.py
|
|
|
16
16
|
src/alignmenter/_version.py
|
|
17
17
|
src/alignmenter/cli.py
|
|
18
18
|
src/alignmenter/config.py
|
|
19
|
+
src/alignmenter/dataset_cli.py
|
|
19
20
|
src/alignmenter/release_cli.py
|
|
20
21
|
src/alignmenter/run_config.py
|
|
21
22
|
src/alignmenter/runner.py
|
|
@@ -77,9 +78,11 @@ src/alignmenter/providers/local.py
|
|
|
77
78
|
src/alignmenter/providers/openai.py
|
|
78
79
|
src/alignmenter/reporting/__init__.py
|
|
79
80
|
src/alignmenter/reporting/durable.py
|
|
81
|
+
src/alignmenter/reporting/github_comment.py
|
|
80
82
|
src/alignmenter/reporting/html.py
|
|
81
83
|
src/alignmenter/reporting/json_out.py
|
|
82
84
|
src/alignmenter/schemas/__init__.py
|
|
85
|
+
src/alignmenter/schemas/dataset.py
|
|
83
86
|
src/alignmenter/schemas/evaluation.py
|
|
84
87
|
src/alignmenter/schemas/execution.py
|
|
85
88
|
src/alignmenter/schemas/gates.py
|
|
@@ -120,9 +123,11 @@ tests/test_cli_import.py
|
|
|
120
123
|
tests/test_cli_init.py
|
|
121
124
|
tests/test_cli_run_config.py
|
|
122
125
|
tests/test_config.py
|
|
126
|
+
tests/test_dataset_management.py
|
|
123
127
|
tests/test_durable_evaluations.py
|
|
124
128
|
tests/test_durable_execution.py
|
|
125
129
|
tests/test_faithfulness.py
|
|
130
|
+
tests/test_github_comment.py
|
|
126
131
|
tests/test_grounding.py
|
|
127
132
|
tests/test_html_report.py
|
|
128
133
|
tests/test_judge_providers.py
|