workflowval 0.1.0__py3-none-any.whl
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.
- workflowval/__init__.py +3 -0
- workflowval/cli.py +584 -0
- workflowval/gen/__init__.py +194 -0
- workflowval/gen/source.py +109 -0
- workflowval/gen/style.md +17 -0
- workflowval/gen/templates/.gitkeep +0 -0
- workflowval/gen/templates/conftest.py.tmpl +332 -0
- workflowval/loop/__init__.py +55 -0
- workflowval/loop/runner.py +488 -0
- workflowval/loop/stats.py +178 -0
- workflowval/loop/trace.py +29 -0
- workflowval/models/__init__.py +19 -0
- workflowval/models/base.py +68 -0
- workflowval/models/fake.py +55 -0
- workflowval/models/litellm_adapter.py +153 -0
- workflowval/pipeline.py +906 -0
- workflowval/planner/__init__.py +62 -0
- workflowval/prompts/__init__.py +34 -0
- workflowval/prompts/planner.md +37 -0
- workflowval/prompts/reviewer.md +30 -0
- workflowval/prompts/task_answer_review.md +14 -0
- workflowval/prompts/task_finding.md +17 -0
- workflowval/prompts/task_fix_test.md +6 -0
- workflowval/prompts/task_fix_workflow.md +7 -0
- workflowval/prompts/task_patch.md +9 -0
- workflowval/prompts/task_plan.md +13 -0
- workflowval/prompts/task_plan_gaps.md +6 -0
- workflowval/prompts/task_report.md +9 -0
- workflowval/prompts/task_review_plan.md +17 -0
- workflowval/prompts/task_review_results.md +19 -0
- workflowval/prompts/task_triage.md +9 -0
- workflowval/prompts/writer.md +11 -0
- workflowval/py.typed +0 -0
- workflowval/redact.py +97 -0
- workflowval/report/__init__.py +162 -0
- workflowval/report/curl.py +75 -0
- workflowval/schemas/__init__.py +78 -0
- workflowval/schemas/_base.py +43 -0
- workflowval/schemas/auth.py +66 -0
- workflowval/schemas/labels.py +31 -0
- workflowval/schemas/results.py +222 -0
- workflowval/schemas/review.py +94 -0
- workflowval/schemas/state.py +32 -0
- workflowval/schemas/stats.py +36 -0
- workflowval/schemas/workflow.py +148 -0
- workflowval/spec/__init__.py +38 -0
- workflowval/spec/graph.py +196 -0
- workflowval/spec/loader.py +102 -0
- workflowval/spec/validation.py +98 -0
- workflowval/testrun.py +159 -0
- workflowval/tools/__init__.py +6 -0
- workflowval/tools/base.py +49 -0
- workflowval/tools/registry.py +637 -0
- workflowval/triage/__init__.py +199 -0
- workflowval-0.1.0.dist-info/METADATA +215 -0
- workflowval-0.1.0.dist-info/RECORD +59 -0
- workflowval-0.1.0.dist-info/WHEEL +4 -0
- workflowval-0.1.0.dist-info/entry_points.txt +2 -0
- workflowval-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Pydantic models for every data format. Reviewed by the user before M1."""
|
|
2
|
+
|
|
3
|
+
from workflowval.schemas._base import atomic_write_text, load_json, save_json
|
|
4
|
+
from workflowval.schemas.auth import ApiKeyAuth, AuthConfig, BasicAuth, BearerAuth, LoginAuth
|
|
5
|
+
from workflowval.schemas.labels import Labels, SeededBug
|
|
6
|
+
from workflowval.schemas.results import (
|
|
7
|
+
Action,
|
|
8
|
+
ActionKind,
|
|
9
|
+
Confidence,
|
|
10
|
+
Evidence,
|
|
11
|
+
Finding,
|
|
12
|
+
HttpExchange,
|
|
13
|
+
Label,
|
|
14
|
+
RunResults,
|
|
15
|
+
TestResult,
|
|
16
|
+
TestStatus,
|
|
17
|
+
Verdict,
|
|
18
|
+
)
|
|
19
|
+
from workflowval.schemas.review import (
|
|
20
|
+
Answer,
|
|
21
|
+
Decision,
|
|
22
|
+
MissingJourney,
|
|
23
|
+
Review,
|
|
24
|
+
ReviewLog,
|
|
25
|
+
WorkflowFix,
|
|
26
|
+
)
|
|
27
|
+
from workflowval.schemas.state import RunState
|
|
28
|
+
from workflowval.schemas.stats import CallCounts, RunStats, StatsTotal
|
|
29
|
+
from workflowval.schemas.workflow import (
|
|
30
|
+
Basis,
|
|
31
|
+
CleanupStep,
|
|
32
|
+
Expect,
|
|
33
|
+
KnownIssue,
|
|
34
|
+
Source,
|
|
35
|
+
Step,
|
|
36
|
+
Workflow,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"Action",
|
|
41
|
+
"ActionKind",
|
|
42
|
+
"Answer",
|
|
43
|
+
"ApiKeyAuth",
|
|
44
|
+
"AuthConfig",
|
|
45
|
+
"BasicAuth",
|
|
46
|
+
"Basis",
|
|
47
|
+
"BearerAuth",
|
|
48
|
+
"CallCounts",
|
|
49
|
+
"CleanupStep",
|
|
50
|
+
"Confidence",
|
|
51
|
+
"Decision",
|
|
52
|
+
"Evidence",
|
|
53
|
+
"Expect",
|
|
54
|
+
"Finding",
|
|
55
|
+
"HttpExchange",
|
|
56
|
+
"KnownIssue",
|
|
57
|
+
"Label",
|
|
58
|
+
"Labels",
|
|
59
|
+
"LoginAuth",
|
|
60
|
+
"MissingJourney",
|
|
61
|
+
"Review",
|
|
62
|
+
"ReviewLog",
|
|
63
|
+
"RunResults",
|
|
64
|
+
"RunState",
|
|
65
|
+
"RunStats",
|
|
66
|
+
"SeededBug",
|
|
67
|
+
"Source",
|
|
68
|
+
"StatsTotal",
|
|
69
|
+
"Step",
|
|
70
|
+
"TestResult",
|
|
71
|
+
"TestStatus",
|
|
72
|
+
"Verdict",
|
|
73
|
+
"Workflow",
|
|
74
|
+
"WorkflowFix",
|
|
75
|
+
"atomic_write_text",
|
|
76
|
+
"load_json",
|
|
77
|
+
"save_json",
|
|
78
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Shared base model and JSON file helpers for every data format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TypeVar
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T", bound=BaseModel)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Strict(BaseModel):
|
|
17
|
+
"""Base for on-disk formats: unknown fields are errors, so typos fail loudly."""
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(extra="forbid")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def atomic_write_text(path: Path, text: str) -> None:
|
|
23
|
+
"""Write text so that a killed process never leaves a half-written file behind."""
|
|
24
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
|
|
26
|
+
try:
|
|
27
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
28
|
+
handle.write(text)
|
|
29
|
+
os.replace(tmp, path)
|
|
30
|
+
except BaseException:
|
|
31
|
+
Path(tmp).unlink(missing_ok=True)
|
|
32
|
+
raise
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def save_json(model: BaseModel, path: Path) -> None:
|
|
36
|
+
"""Write a model as indented JSON, atomically."""
|
|
37
|
+
payload = json.loads(model.model_dump_json(exclude_none=True))
|
|
38
|
+
atomic_write_text(path, json.dumps(payload, indent=2) + "\n")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_json(cls: type[T], path: Path) -> T:
|
|
42
|
+
"""Read and validate a JSON file. Raises pydantic.ValidationError on bad data."""
|
|
43
|
+
return cls.model_validate_json(path.read_text(encoding="utf-8"))
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""The auth config file (FR-2). It names env vars and never holds a secret value (NFR-7)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import Field, StringConstraints
|
|
8
|
+
|
|
9
|
+
from workflowval.schemas._base import Strict
|
|
10
|
+
|
|
11
|
+
EnvVarName = Annotated[str, StringConstraints(pattern=r"^[A-Z_][A-Z0-9_]*$")]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApiKeyAuth(Strict):
|
|
15
|
+
"""An API key sent in a header or query parameter."""
|
|
16
|
+
|
|
17
|
+
type: Literal["api_key"]
|
|
18
|
+
name: str = Field(min_length=1, description="Header or query parameter name")
|
|
19
|
+
location: Literal["header", "query"]
|
|
20
|
+
value_env: EnvVarName
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BearerAuth(Strict):
|
|
24
|
+
"""A bearer token."""
|
|
25
|
+
|
|
26
|
+
type: Literal["bearer"]
|
|
27
|
+
token_env: EnvVarName
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BasicAuth(Strict):
|
|
31
|
+
"""HTTP basic auth."""
|
|
32
|
+
|
|
33
|
+
type: Literal["basic"]
|
|
34
|
+
username_env: EnvVarName
|
|
35
|
+
password_env: EnvVarName
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LoginAuth(Strict):
|
|
39
|
+
"""Call a login operation, extract a token, reuse it and refresh it on 401.
|
|
40
|
+
|
|
41
|
+
Leave `token_header` and `token_prefix` out and the agent works out how to send the token, from
|
|
42
|
+
the spec's security section, then the docs, then the login response and what the API accepts.
|
|
43
|
+
Values set here always win. The agent never sees the secret values.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
type: Literal["login"]
|
|
47
|
+
op: str = Field(min_length=1, description="Login operationId")
|
|
48
|
+
body_env: dict[str, EnvVarName] = Field(
|
|
49
|
+
description="Request body field to env var name. `user.email` nests the field.",
|
|
50
|
+
)
|
|
51
|
+
token_path: str = Field(pattern=r"^\$", description="JSONPath of the token in the response")
|
|
52
|
+
token_header: str | None = Field(default=None, description="Override, e.g. Cookie")
|
|
53
|
+
token_prefix: str | None = Field(default=None, description="Override, e.g. token=")
|
|
54
|
+
refresh_on_401: bool = True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
AuthScheme = Annotated[
|
|
58
|
+
ApiKeyAuth | BearerAuth | BasicAuth | LoginAuth,
|
|
59
|
+
Field(discriminator="type"),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AuthConfig(Strict):
|
|
64
|
+
"""Top level of the auth config file."""
|
|
65
|
+
|
|
66
|
+
scheme: AuthScheme
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""`benchmarks/seeded/labels.json`: ground truth for the seeded-bug app (Evaluation)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import Field, model_validator
|
|
8
|
+
|
|
9
|
+
from workflowval.schemas._base import Strict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SeededBug(Strict):
|
|
13
|
+
"""One planted defect or conflict and the label it should get."""
|
|
14
|
+
|
|
15
|
+
id: str = Field(min_length=1)
|
|
16
|
+
description: str = Field(min_length=1)
|
|
17
|
+
expected_label: Literal["API_BUG", "SPEC_DRIFT", "CONFLICT"]
|
|
18
|
+
operations: list[str] = Field(min_length=1, description="operationIds where it can be seen")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Labels(Strict):
|
|
22
|
+
"""The whole ground-truth file."""
|
|
23
|
+
|
|
24
|
+
bugs: list[SeededBug] = Field(min_length=1)
|
|
25
|
+
|
|
26
|
+
@model_validator(mode="after")
|
|
27
|
+
def _ids_are_unique(self) -> Labels:
|
|
28
|
+
ids = [bug.id for bug in self.bugs]
|
|
29
|
+
if len(ids) != len(set(ids)):
|
|
30
|
+
raise ValueError("bug ids must be unique")
|
|
31
|
+
return self
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""`results.json`: per-test outcomes and failure verdicts (FR-6, FR-8, FR-11)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import Field, computed_field, model_validator
|
|
10
|
+
|
|
11
|
+
from workflowval.schemas._base import Strict
|
|
12
|
+
from workflowval.schemas.review import Review
|
|
13
|
+
from workflowval.schemas.workflow import Basis
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Label(StrEnum):
|
|
17
|
+
"""Every failure ends as exactly one label."""
|
|
18
|
+
|
|
19
|
+
TEST_BUG = "TEST_BUG"
|
|
20
|
+
API_BUG = "API_BUG"
|
|
21
|
+
SPEC_DRIFT = "SPEC_DRIFT"
|
|
22
|
+
UNRESOLVED = "UNRESOLVED"
|
|
23
|
+
CONFLICT = "CONFLICT"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Confidence(StrEnum):
|
|
27
|
+
"""How sure the verdict is."""
|
|
28
|
+
|
|
29
|
+
LOW = "low"
|
|
30
|
+
MED = "med"
|
|
31
|
+
HIGH = "high"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestStatus(StrEnum):
|
|
35
|
+
"""What pytest reported."""
|
|
36
|
+
|
|
37
|
+
__test__ = False
|
|
38
|
+
|
|
39
|
+
PASSED = "passed"
|
|
40
|
+
FAILED = "failed"
|
|
41
|
+
ERROR = "error"
|
|
42
|
+
SKIPPED = "skipped"
|
|
43
|
+
XFAILED = "xfailed"
|
|
44
|
+
XPASSED = "xpassed"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_OK_STATUSES = frozenset({TestStatus.PASSED, TestStatus.XFAILED, TestStatus.SKIPPED})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ActionKind(StrEnum):
|
|
51
|
+
"""What was done about a failure."""
|
|
52
|
+
|
|
53
|
+
PATCHED_TEST = "patched_test"
|
|
54
|
+
BUG_REPORT = "bug_report"
|
|
55
|
+
SPEC_PATCH = "spec_patch"
|
|
56
|
+
NONE = "none"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class HttpExchange(Strict):
|
|
60
|
+
"""One request/response pair. Secrets must be redacted before this is built."""
|
|
61
|
+
|
|
62
|
+
method: str
|
|
63
|
+
url: str
|
|
64
|
+
request_headers: dict[str, str] = Field(default_factory=dict)
|
|
65
|
+
request_body: Any = None
|
|
66
|
+
status: int | None = Field(default=None, description="None when no response arrived")
|
|
67
|
+
response_headers: dict[str, str] = Field(default_factory=dict)
|
|
68
|
+
response_body: Any = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Evidence(Strict):
|
|
72
|
+
"""What a verdict rests on."""
|
|
73
|
+
|
|
74
|
+
exchanges: list[HttpExchange] = Field(default_factory=list)
|
|
75
|
+
spec_excerpt: str | None = None
|
|
76
|
+
docs_excerpt: str | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Action(Strict):
|
|
80
|
+
"""The outcome of a verdict, with the file it produced."""
|
|
81
|
+
|
|
82
|
+
kind: ActionKind
|
|
83
|
+
path: str | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Verdict(Strict):
|
|
87
|
+
"""One label for one failure, with confidence, evidence and an action."""
|
|
88
|
+
|
|
89
|
+
label: Label
|
|
90
|
+
confidence: Confidence
|
|
91
|
+
reason: str = Field(min_length=1)
|
|
92
|
+
evidence: Evidence = Field(default_factory=Evidence)
|
|
93
|
+
action: Action = Field(default_factory=lambda: Action(kind=ActionKind.NONE))
|
|
94
|
+
fix_target: Literal["test", "workflow"] | None = Field(
|
|
95
|
+
default=None, description="For a TEST_BUG: what's wrong, the test code or the workflow"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Finding(Strict):
|
|
100
|
+
"""A response that breaks the spec, once however many tests saw it (FR-8, FR-10).
|
|
101
|
+
|
|
102
|
+
`label` is None until the Reviewer rules on it. `TEST_BUG` means it ruled this isn't a real
|
|
103
|
+
problem, for example a spec that's merely loose.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
id: str = Field(min_length=1)
|
|
107
|
+
message: str = Field(min_length=1)
|
|
108
|
+
operations: list[str] = Field(default_factory=list)
|
|
109
|
+
tests: list[str] = Field(default_factory=list)
|
|
110
|
+
label: Label | None = None
|
|
111
|
+
confidence: Confidence | None = None
|
|
112
|
+
reason: str | None = None
|
|
113
|
+
action: Action = Field(default_factory=lambda: Action(kind=ActionKind.NONE))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TestResult(Strict):
|
|
117
|
+
"""The result of one test, keeping the last 3 HTTP exchanges (FR-6)."""
|
|
118
|
+
|
|
119
|
+
__test__ = False
|
|
120
|
+
|
|
121
|
+
id: str = Field(min_length=1, description="pytest node id")
|
|
122
|
+
workflow_id: str
|
|
123
|
+
basis: Basis
|
|
124
|
+
status: TestStatus
|
|
125
|
+
duration_s: float = Field(ge=0)
|
|
126
|
+
failing_assertion: str | None = None
|
|
127
|
+
stdout: str = ""
|
|
128
|
+
exchanges: list[HttpExchange] = Field(default_factory=list, max_length=3)
|
|
129
|
+
fix_attempts: int = Field(default=0, ge=0)
|
|
130
|
+
verdict: Verdict | None = None
|
|
131
|
+
|
|
132
|
+
@model_validator(mode="after")
|
|
133
|
+
def _assumed_rules_cap_at_medium(self) -> TestResult:
|
|
134
|
+
if (
|
|
135
|
+
self.basis is Basis.ASSUMED
|
|
136
|
+
and self.verdict is not None
|
|
137
|
+
and self.verdict.confidence is Confidence.HIGH
|
|
138
|
+
):
|
|
139
|
+
raise ValueError("a failure on an assumed workflow is capped at medium confidence")
|
|
140
|
+
return self
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class RunResults(Strict):
|
|
144
|
+
"""Everything a run reports."""
|
|
145
|
+
|
|
146
|
+
stop_reason: str
|
|
147
|
+
stop_detail: str | None = None
|
|
148
|
+
tests: list[TestResult] = Field(default_factory=list)
|
|
149
|
+
findings: list[Finding] = Field(default_factory=list)
|
|
150
|
+
reviews: list[Review] = Field(default_factory=list)
|
|
151
|
+
tasks_stopped: list[str] = Field(
|
|
152
|
+
default_factory=list,
|
|
153
|
+
description="Tasks that hit their call limit, so may be incomplete",
|
|
154
|
+
)
|
|
155
|
+
uncovered_operations: list[str] = Field(
|
|
156
|
+
default_factory=list, description="Spec operations no workflow calls"
|
|
157
|
+
)
|
|
158
|
+
input_tokens: int = Field(default=0, ge=0)
|
|
159
|
+
output_tokens: int = Field(default=0, ge=0)
|
|
160
|
+
cost_usd: float = Field(default=0.0, ge=0, description="Sum over calls with a known price")
|
|
161
|
+
unpriced_calls: int = Field(default=0, ge=0, description="Calls missing from cost_usd")
|
|
162
|
+
|
|
163
|
+
@model_validator(mode="before")
|
|
164
|
+
@classmethod
|
|
165
|
+
def _ignore_computed_fields(cls, data: Any) -> Any:
|
|
166
|
+
"""`label_counts` is written for readers and computed again on load."""
|
|
167
|
+
if isinstance(data, dict):
|
|
168
|
+
return {key: value for key, value in data.items() if key != "label_counts"}
|
|
169
|
+
return data
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def has_unexpected_failures(self) -> bool:
|
|
173
|
+
"""True when a test failed in a way nobody has reviewed and marked as expected."""
|
|
174
|
+
return any(t.status not in _OK_STATUSES for t in self.tests)
|
|
175
|
+
|
|
176
|
+
def ci_failures(self, *, max_api_bugs: int, max_spec_drift: int) -> list[str]:
|
|
177
|
+
"""Why `--ci` should fail the build. Expected failures still count toward the thresholds."""
|
|
178
|
+
reasons = []
|
|
179
|
+
unresolved = sum(
|
|
180
|
+
t.status not in _OK_STATUSES
|
|
181
|
+
or (t.verdict is not None and t.verdict.label is Label.UNRESOLVED)
|
|
182
|
+
for t in self.tests
|
|
183
|
+
)
|
|
184
|
+
if unresolved:
|
|
185
|
+
reasons.append(f"{unresolved} unresolved")
|
|
186
|
+
counts = self.label_counts
|
|
187
|
+
if counts.get("API_BUG", 0) > max_api_bugs:
|
|
188
|
+
reasons.append(f"{counts['API_BUG']} API_BUG, above the limit of {max_api_bugs}")
|
|
189
|
+
if counts.get("SPEC_DRIFT", 0) > max_spec_drift:
|
|
190
|
+
reasons.append(
|
|
191
|
+
f"{counts['SPEC_DRIFT']} SPEC_DRIFT, above the limit of {max_spec_drift}"
|
|
192
|
+
)
|
|
193
|
+
return reasons
|
|
194
|
+
|
|
195
|
+
def summary(self) -> str:
|
|
196
|
+
"""One line of counts, keeping expected failures apart from passes."""
|
|
197
|
+
passed = sum(t.status is TestStatus.PASSED for t in self.tests)
|
|
198
|
+
parts = [f"{passed} passed"]
|
|
199
|
+
expected = Counter(
|
|
200
|
+
t.verdict.label.value
|
|
201
|
+
for t in self.tests
|
|
202
|
+
if t.status is TestStatus.XFAILED and t.verdict is not None
|
|
203
|
+
)
|
|
204
|
+
parts += [f"{n} expected to fail ({label})" for label, n in sorted(expected.items())]
|
|
205
|
+
skipped = sum(t.status is TestStatus.SKIPPED for t in self.tests)
|
|
206
|
+
if skipped:
|
|
207
|
+
parts.append(f"{skipped} skipped")
|
|
208
|
+
failed = len(self.tests) - passed - skipped - sum(expected.values())
|
|
209
|
+
if failed:
|
|
210
|
+
parts.append(f"{failed} failed")
|
|
211
|
+
confirmed = sum(f.label not in {None, Label.TEST_BUG} for f in self.findings)
|
|
212
|
+
if confirmed:
|
|
213
|
+
parts.append(f"{confirmed} spec mismatch{'es' if confirmed > 1 else ''}")
|
|
214
|
+
return ", ".join(parts)
|
|
215
|
+
|
|
216
|
+
@computed_field # type: ignore[prop-decorator]
|
|
217
|
+
@property
|
|
218
|
+
def label_counts(self) -> dict[str, int]:
|
|
219
|
+
"""Number of tests per label."""
|
|
220
|
+
labels = [t.verdict.label.value for t in self.tests if t.verdict is not None]
|
|
221
|
+
labels += [f.label.value for f in self.findings if f.label not in {None, Label.TEST_BUG}]
|
|
222
|
+
return dict(Counter(labels))
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""`review.json`: the Reviewer's fixes and missing journeys, with the Planner's answers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
from pydantic import Field, model_validator
|
|
8
|
+
|
|
9
|
+
from workflowval.schemas._base import Strict
|
|
10
|
+
from workflowval.schemas.workflow import Basis
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Decision(StrEnum):
|
|
14
|
+
"""How the Planner answered a review item."""
|
|
15
|
+
|
|
16
|
+
ACCEPTED = "accepted"
|
|
17
|
+
REJECTED = "rejected"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Answer(Strict):
|
|
21
|
+
"""The Planner's answer. Nothing is dropped silently, so a rejection needs a reason."""
|
|
22
|
+
|
|
23
|
+
decision: Decision
|
|
24
|
+
reason: str | None = None
|
|
25
|
+
workflow_id: str | None = Field(default=None, description="File that carries out the answer")
|
|
26
|
+
|
|
27
|
+
@model_validator(mode="after")
|
|
28
|
+
def _check(self) -> Answer:
|
|
29
|
+
if self.decision is Decision.REJECTED and not self.reason:
|
|
30
|
+
raise ValueError("a rejection must give a reason")
|
|
31
|
+
if self.decision is Decision.ACCEPTED and not self.workflow_id:
|
|
32
|
+
raise ValueError("an accepted item must name its workflow_id")
|
|
33
|
+
return self
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class WorkflowFix(Strict):
|
|
37
|
+
"""A workflow that is wrong or weak."""
|
|
38
|
+
|
|
39
|
+
id: str = Field(min_length=1)
|
|
40
|
+
workflow_id: str
|
|
41
|
+
reason: str = Field(min_length=1)
|
|
42
|
+
answer: Answer | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MissingJourney(Strict):
|
|
46
|
+
"""A user flow the workflows do not cover."""
|
|
47
|
+
|
|
48
|
+
id: str = Field(min_length=1)
|
|
49
|
+
user: str = Field(min_length=1, description="The user named in the docs, or 'API consumer'")
|
|
50
|
+
steps: list[str] = Field(min_length=1)
|
|
51
|
+
rule: str = Field(min_length=1)
|
|
52
|
+
basis: Basis
|
|
53
|
+
answer: Answer | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Review(Strict):
|
|
57
|
+
"""One review round's output."""
|
|
58
|
+
|
|
59
|
+
round: int = Field(ge=1)
|
|
60
|
+
after_tests: bool = Field(default=False, description="True if it followed a test run")
|
|
61
|
+
fixes: list[WorkflowFix] = Field(default_factory=list)
|
|
62
|
+
missing_journeys: list[MissingJourney] = Field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
@model_validator(mode="after")
|
|
65
|
+
def _ids_are_unique(self) -> Review:
|
|
66
|
+
ids = [item.id for item in self._items()]
|
|
67
|
+
if len(ids) != len(set(ids)):
|
|
68
|
+
raise ValueError("review item ids must be unique")
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def unanswered(self) -> list[str]:
|
|
72
|
+
"""Ids of items the Planner has not answered yet."""
|
|
73
|
+
return [item.id for item in self._items() if item.answer is None]
|
|
74
|
+
|
|
75
|
+
def _items(self) -> list[WorkflowFix | MissingJourney]:
|
|
76
|
+
return [*self.fixes, *self.missing_journeys]
|
|
77
|
+
|
|
78
|
+
def items(self) -> list[WorkflowFix | MissingJourney]:
|
|
79
|
+
"""Every fix and journey in this round."""
|
|
80
|
+
return self._items()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ReviewLog(Strict):
|
|
84
|
+
"""`review.json`: every round, so nothing proposed is ever dropped."""
|
|
85
|
+
|
|
86
|
+
rounds: list[Review] = Field(default_factory=list)
|
|
87
|
+
|
|
88
|
+
def item(self, item_id: str) -> WorkflowFix | MissingJourney | None:
|
|
89
|
+
"""Find an item in any round."""
|
|
90
|
+
return next((i for r in self.rounds for i in r.items() if i.id == item_id), None)
|
|
91
|
+
|
|
92
|
+
def unanswered(self) -> list[str]:
|
|
93
|
+
"""Ids of items the Planner has not answered yet, in any round."""
|
|
94
|
+
return [item_id for review in self.rounds for item_id in review.unanswered()]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""`.workflowval/state.json`: the resume point and spec hash (FR-13)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
from workflowval.schemas._base import Strict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RunState(Strict):
|
|
13
|
+
"""A checkpoint, rewritten atomically as the run advances."""
|
|
14
|
+
|
|
15
|
+
format_version: Literal[1] = 1
|
|
16
|
+
spec_hash: str = ""
|
|
17
|
+
completed_steps: list[str] = Field(default_factory=list)
|
|
18
|
+
review_rounds: int = Field(default=0, ge=0)
|
|
19
|
+
iterations: int = Field(default=0, ge=0)
|
|
20
|
+
steps_used: int = Field(default=0, ge=0)
|
|
21
|
+
fix_attempts: dict[str, int] = Field(default_factory=dict)
|
|
22
|
+
closed_tests: dict[str, str] = Field(
|
|
23
|
+
default_factory=dict, description="Test id to 'passed' or its final label"
|
|
24
|
+
)
|
|
25
|
+
open_tests: list[str] = Field(default_factory=list)
|
|
26
|
+
input_tokens: int = Field(default=0, ge=0)
|
|
27
|
+
output_tokens: int = Field(default=0, ge=0)
|
|
28
|
+
cost_usd: float = Field(default=0.0, ge=0)
|
|
29
|
+
unpriced_calls: int = Field(default=0, ge=0)
|
|
30
|
+
tasks_stopped: list[str] = Field(default_factory=list)
|
|
31
|
+
cost_tracked: bool = True
|
|
32
|
+
stop_reason: str | None = None
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""`.workflowval/stats.json` and `stats_history.jsonl`: names and counts, never arguments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
|
|
7
|
+
from workflowval.schemas._base import Strict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CallCounts(Strict):
|
|
11
|
+
"""Model calls, and tool calls by tool name, including refused and invalid attempts."""
|
|
12
|
+
|
|
13
|
+
model_calls: int = Field(default=0, ge=0)
|
|
14
|
+
tool_calls: dict[str, int] = Field(default_factory=dict)
|
|
15
|
+
cost_usd: float = Field(default=0.0, ge=0, description="Sum over calls with a known price")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StatsTotal(Strict):
|
|
19
|
+
"""Totals for a run. `model_calls` is what `--max-steps` limits."""
|
|
20
|
+
|
|
21
|
+
model_calls: int = Field(default=0, ge=0)
|
|
22
|
+
tool_calls: int = Field(default=0, ge=0)
|
|
23
|
+
cost_usd: float = Field(default=0.0, ge=0)
|
|
24
|
+
limit: int = Field(ge=1, description="The --max-steps value for this run")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RunStats(Strict):
|
|
28
|
+
"""One run's calls. `run` holds work that belongs to no single workflow."""
|
|
29
|
+
|
|
30
|
+
run: CallCounts = Field(default_factory=CallCounts)
|
|
31
|
+
workflows: dict[str, CallCounts] = Field(default_factory=dict)
|
|
32
|
+
roles: dict[str, CallCounts] = Field(default_factory=dict)
|
|
33
|
+
total: StatsTotal
|
|
34
|
+
spec_hash: str = ""
|
|
35
|
+
stop_reason: str | None = None
|
|
36
|
+
finished_at: str | None = None
|