docassert 0.10.1__tar.gz → 0.12.0__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.
- {docassert-0.10.1/docassert.egg-info → docassert-0.12.0}/PKG-INFO +1 -1
- {docassert-0.10.1 → docassert-0.12.0}/docassert/__init__.py +1 -1
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/consistency.yaml +16 -2
- {docassert-0.10.1 → docassert-0.12.0}/docassert/consistency.py +41 -13
- {docassert-0.10.1 → docassert-0.12.0}/docassert/semantic.py +43 -10
- {docassert-0.10.1 → docassert-0.12.0/docassert.egg-info}/PKG-INFO +1 -1
- {docassert-0.10.1 → docassert-0.12.0}/docassert.egg-info/SOURCES.txt +1 -0
- docassert-0.12.0/tests/test_semantic.py +176 -0
- {docassert-0.10.1 → docassert-0.12.0}/LICENSE +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/NOTICE +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/README.md +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/__main__.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/adr.criteria.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/benefits-realization.criteria.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/brd.criteria.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/business-case.criteria.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/charter.criteria.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/data-migration-plan.criteria.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/frnfr.criteria.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/hypercare-plan.criteria.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/post-implementation-review.criteria.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/prd.criteria.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/project.criteria.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/qa-test-plan.criteria.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/raci-stakeholder.criteria.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/release-cutover-plan.criteria.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/risk-register.criteria.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/rollback-plan.criteria.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/runbook.criteria.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/status-report.criteria.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/test-cases.criteria.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/user-story.criteria.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/profiles/agile-delivery.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/profiles/lean-startup.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/profiles/regulated-industry.yaml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/adr.schema.json +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/benefits-realization.schema.json +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/brd.schema.json +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/business-case.schema.json +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/charter.schema.json +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/data-migration-plan.schema.json +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/frnfr.schema.json +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/hypercare-plan.schema.json +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/post-implementation-review.schema.json +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/prd.schema.json +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/project.schema.json +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/qa-test-plan.schema.json +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/raci-stakeholder.schema.json +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/release-cutover-plan.schema.json +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/risk-register.schema.json +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/rollback-plan.schema.json +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/runbook.schema.json +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/status-report.schema.json +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/test-cases.schema.json +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/user-story.schema.json +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/skills/doc-to-pmo/SKILL.md +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/adr.template.md +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/benefits-realization.template.md +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/brd.template.md +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/business-case.template.md +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/charter.template.md +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/data-migration-plan.template.md +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/frnfr.template.md +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/hypercare-plan.template.md +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/post-implementation-review.template.md +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/prd.template.md +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/project.template.md +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/qa-test-plan.template.md +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/raci-stakeholder.template.md +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/release-cutover-plan.template.md +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/risk-register.template.md +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/rollback-plan.template.md +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/runbook.template.md +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/status-report.template.md +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/test-cases.template.md +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/user-story.template.md +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/bridge/__init__.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/bridge/board.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/bridge/gh.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/bridge/ops.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/bridge/plan.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/cli.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/config.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/extract.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/graph.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/loader.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/models.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/profiles.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/projects.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/report.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/rtm.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/scaffold.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/status/__init__.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/status/derive.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/status/render.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert/structural.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert.egg-info/dependency_links.txt +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert.egg-info/entry_points.txt +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert.egg-info/requires.txt +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/docassert.egg-info/top_level.txt +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/pyproject.toml +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/setup.cfg +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/tests/test_badge.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/tests/test_bridge.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/tests/test_config.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/tests/test_consistency.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/tests/test_defects.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/tests/test_draft_relaxation.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/tests/test_extract.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/tests/test_graph.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/tests/test_json_report.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/tests/test_kinds_delivery.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/tests/test_kinds_governance.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/tests/test_kinds_operate.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/tests/test_kinds_reporting.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/tests/test_profiles.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/tests/test_projects.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/tests/test_scaffold.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/tests/test_status.py +0 -0
- {docassert-0.10.1 → docassert-0.12.0}/tests/test_structural.py +0 -0
|
@@ -39,7 +39,18 @@ coverage:
|
|
|
39
39
|
# (0 = no cap).
|
|
40
40
|
alignment_limit: 25
|
|
41
41
|
|
|
42
|
+
# Rules match in order; the first matching rule grades a link. Typed rules
|
|
43
|
+
# (child_type/parent_type) come before the generic ones.
|
|
42
44
|
alignment:
|
|
45
|
+
- relation: traces
|
|
46
|
+
child_type: PR
|
|
47
|
+
parent_type: BR
|
|
48
|
+
prompt: >
|
|
49
|
+
The PARENT is a business outcome or KPI; the CHILD is a product
|
|
50
|
+
mechanism tracing to it. Judge whether delivering the child would
|
|
51
|
+
plausibly and materially contribute to achieving the parent outcome.
|
|
52
|
+
The child need not restate the metric; penalise only mechanisms that
|
|
53
|
+
would not meaningfully move the parent outcome, or that contradict it.
|
|
43
54
|
- relation: traces
|
|
44
55
|
prompt: >
|
|
45
56
|
Judge whether the CHILD requirement genuinely refines or implements the
|
|
@@ -47,8 +58,11 @@ alignment:
|
|
|
47
58
|
related. Penalise links that don't actually follow from the parent.
|
|
48
59
|
- relation: verifies
|
|
49
60
|
prompt: >
|
|
50
|
-
Judge whether the acceptance criterion
|
|
51
|
-
|
|
61
|
+
Judge whether the acceptance criterion concretely and testably verifies
|
|
62
|
+
some aspect of the requirement it targets. Several criteria may share
|
|
63
|
+
one requirement, each covering a slice, so do not penalise a criterion
|
|
64
|
+
for leaving other clauses to its siblings. Penalise criteria that are
|
|
65
|
+
untestable, verify nothing the requirement states, or contradict it.
|
|
52
66
|
- relation: tests
|
|
53
67
|
prompt: >
|
|
54
68
|
Judge whether the test case actually exercises the acceptance criterion
|
|
@@ -18,6 +18,7 @@ from pathlib import Path
|
|
|
18
18
|
import yaml
|
|
19
19
|
|
|
20
20
|
from . import config as config_mod
|
|
21
|
+
from . import semantic
|
|
21
22
|
from .graph import build_graph
|
|
22
23
|
from .models import CheckResult
|
|
23
24
|
from .semantic import run_alignment
|
|
@@ -134,37 +135,64 @@ def check_profile_completeness(documents_dir: str | Path = "documents") -> Check
|
|
|
134
135
|
# ── semantic (advisory) ────────────────────────────────────────────────────
|
|
135
136
|
# Each alignment edge costs one API call, so a large graph could otherwise run
|
|
136
137
|
# away on cost. Cap per run; tune with `alignment_limit` in consistency.yaml
|
|
137
|
-
# (0 disables the cap).
|
|
138
|
+
# (0 disables the cap). The cap budgets API calls, not links: grades already
|
|
139
|
+
# in the semantic cache replay for free, so successive runs that persist
|
|
140
|
+
# `.docassert-cache` walk the whole graph `alignment_limit` links at a time.
|
|
138
141
|
DEFAULT_ALIGNMENT_LIMIT = 25
|
|
139
142
|
|
|
140
143
|
|
|
141
144
|
def run_alignment_checks(graph, config) -> list[CheckResult]:
|
|
142
145
|
edges = [] # (prompt, parent, child, relation)
|
|
146
|
+
seen: set[tuple[str, str, str]] = set()
|
|
147
|
+
# Rules match in order and the first matching rule grades a link, so a
|
|
148
|
+
# typed rule (child_type/parent_type) placed before the generic one can
|
|
149
|
+
# give e.g. PR->BR traces a contribution rubric while other traces keep
|
|
150
|
+
# the refinement rubric.
|
|
143
151
|
for rule in config.get("alignment", []):
|
|
144
152
|
relation, prompt = rule["relation"], rule.get("prompt", "").strip()
|
|
153
|
+
child_type = rule.get("child_type")
|
|
154
|
+
parent_type = rule.get("parent_type")
|
|
145
155
|
for child in graph.all_items():
|
|
156
|
+
if child_type and child.type != child_type:
|
|
157
|
+
continue
|
|
146
158
|
for target in child.targets(relation):
|
|
147
159
|
parent = graph.canonical(target)
|
|
148
|
-
if parent is
|
|
149
|
-
|
|
160
|
+
if parent is None:
|
|
161
|
+
continue
|
|
162
|
+
if parent_type and parent.type != parent_type:
|
|
163
|
+
continue
|
|
164
|
+
key = (child.id, relation, parent.id)
|
|
165
|
+
if key in seen:
|
|
166
|
+
continue
|
|
167
|
+
seen.add(key)
|
|
168
|
+
edges.append((prompt, parent, child, relation))
|
|
150
169
|
|
|
151
170
|
if not edges:
|
|
152
171
|
return []
|
|
153
172
|
|
|
154
|
-
limit = int(config.get("alignment_limit", DEFAULT_ALIGNMENT_LIMIT) or 0)
|
|
155
|
-
note: CheckResult | None = None
|
|
156
|
-
if limit and len(edges) > limit:
|
|
157
|
-
note = CheckResult(
|
|
158
|
-
"alignment-limit", True, False,
|
|
159
|
-
f"graded {limit} of {len(edges)} link(s) — raise `alignment_limit` "
|
|
160
|
-
f"in consistency.yaml to grade more per run",
|
|
161
|
-
kind="semantic", score=None)
|
|
162
|
-
edges = edges[:limit]
|
|
163
|
-
|
|
164
173
|
if not os.environ.get("ANTHROPIC_API_KEY"):
|
|
165
174
|
return [CheckResult("alignment", True, False,
|
|
166
175
|
f"skipped — no ANTHROPIC_API_KEY ({len(edges)} link(s) to grade)",
|
|
167
176
|
kind="semantic", score=None)]
|
|
177
|
+
|
|
178
|
+
limit = int(config.get("alignment_limit", DEFAULT_ALIGNMENT_LIMIT) or 0)
|
|
179
|
+
note: CheckResult | None = None
|
|
180
|
+
if limit:
|
|
181
|
+
# Spend the budget on cache misses; cached grades replay for free.
|
|
182
|
+
fresh = [e for e in edges
|
|
183
|
+
if not semantic.is_cached(e[0], semantic.alignment_content(
|
|
184
|
+
e[1].text, e[2].text))]
|
|
185
|
+
if len(fresh) > limit:
|
|
186
|
+
over_budget = set(id(e) for e in fresh[limit:])
|
|
187
|
+
edges = [e for e in edges if id(e) not in over_budget]
|
|
188
|
+
note = CheckResult(
|
|
189
|
+
"alignment-limit", True, False,
|
|
190
|
+
f"graded {limit} of {len(fresh)} ungraded link(s) "
|
|
191
|
+
f"({len(edges) - limit} from cache) — raise `alignment_limit` in "
|
|
192
|
+
f"consistency.yaml, or persist `.docassert-cache` between runs "
|
|
193
|
+
f"to walk the rest",
|
|
194
|
+
kind="semantic", score=None)
|
|
195
|
+
|
|
168
196
|
results = [run_alignment(f"align:{c.id}-{rel}-{p.id}", prompt, p.text, c.text)
|
|
169
197
|
for prompt, p, c, rel in edges]
|
|
170
198
|
if note is not None:
|
|
@@ -9,12 +9,15 @@ from __future__ import annotations
|
|
|
9
9
|
import hashlib
|
|
10
10
|
import json
|
|
11
11
|
import os
|
|
12
|
+
import re
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
|
|
14
15
|
from .models import CheckResult, Document
|
|
15
16
|
|
|
16
|
-
DEFAULT_MODEL = os.environ.get("
|
|
17
|
-
|
|
17
|
+
DEFAULT_MODEL = (os.environ.get("DOCASSERT_MODEL")
|
|
18
|
+
or os.environ.get("DOCUNIT_MODEL", "claude-sonnet-5"))
|
|
19
|
+
CACHE_DIR = Path(os.environ.get("DOCASSERT_CACHE")
|
|
20
|
+
or os.environ.get("DOCUNIT_CACHE", ".docassert-cache"))
|
|
18
21
|
|
|
19
22
|
_SYSTEM = (
|
|
20
23
|
"You are a meticulous document auditor. You are given one audit criterion "
|
|
@@ -53,6 +56,31 @@ def _cache_put(key: str, value: dict) -> None:
|
|
|
53
56
|
pass # caching is best-effort
|
|
54
57
|
|
|
55
58
|
|
|
59
|
+
def _parse_grade(text: str) -> dict:
|
|
60
|
+
"""Parse the model's JSON grade, tolerating prose wrappers and truncation.
|
|
61
|
+
|
|
62
|
+
Responses put score and pass before the free-text rationale, so a reply
|
|
63
|
+
that ran out of tokens mid-rationale still carries a usable grade.
|
|
64
|
+
"""
|
|
65
|
+
start, end = text.find("{"), text.rfind("}")
|
|
66
|
+
if start != -1 and end > start:
|
|
67
|
+
try:
|
|
68
|
+
return json.loads(text[start:end + 1])
|
|
69
|
+
except json.JSONDecodeError:
|
|
70
|
+
pass
|
|
71
|
+
m = re.search(r'"score"\s*:\s*([0-9.]+)', text)
|
|
72
|
+
if m:
|
|
73
|
+
grade: dict = {"score": float(m.group(1))}
|
|
74
|
+
pm = re.search(r'"pass"\s*:\s*(true|false)', text)
|
|
75
|
+
if pm:
|
|
76
|
+
grade["pass"] = pm.group(1) == "true"
|
|
77
|
+
rm = re.search(r'"rationale"\s*:\s*"(.*)', text, re.DOTALL)
|
|
78
|
+
if rm:
|
|
79
|
+
grade["rationale"] = rm.group(1).rstrip('"').strip() + " [truncated]"
|
|
80
|
+
return grade
|
|
81
|
+
raise ValueError(f"no JSON in model response: {text[:120]!r}")
|
|
82
|
+
|
|
83
|
+
|
|
56
84
|
def _grade(prompt: str, content: str, model: str) -> dict:
|
|
57
85
|
"""Call the Anthropic API and return the parsed JSON grade."""
|
|
58
86
|
import anthropic # imported lazily so structural-only runs need no dependency
|
|
@@ -62,7 +90,7 @@ def _grade(prompt: str, content: str, model: str) -> dict:
|
|
|
62
90
|
# not set it. Grading is kept consistent by the strict JSON rubric instead.
|
|
63
91
|
message = client.messages.create(
|
|
64
92
|
model=model,
|
|
65
|
-
max_tokens=
|
|
93
|
+
max_tokens=800,
|
|
66
94
|
system=_SYSTEM,
|
|
67
95
|
messages=[{
|
|
68
96
|
"role": "user",
|
|
@@ -71,11 +99,7 @@ def _grade(prompt: str, content: str, model: str) -> dict:
|
|
|
71
99
|
)
|
|
72
100
|
text = "".join(getattr(block, "text", "") for block in message.content
|
|
73
101
|
if getattr(block, "type", None) == "text").strip()
|
|
74
|
-
|
|
75
|
-
start, end = text.find("{"), text.rfind("}")
|
|
76
|
-
if start == -1 or end == -1:
|
|
77
|
-
raise ValueError(f"no JSON in model response: {text[:120]!r}")
|
|
78
|
-
return json.loads(text[start:end + 1])
|
|
102
|
+
return _parse_grade(text)
|
|
79
103
|
|
|
80
104
|
|
|
81
105
|
def advisory(check_id: str, prompt: str, content: str,
|
|
@@ -117,8 +141,17 @@ def run_semantic(doc: Document, spec: dict, content: str) -> CheckResult:
|
|
|
117
141
|
)
|
|
118
142
|
|
|
119
143
|
|
|
144
|
+
def alignment_content(parent_text: str, child_text: str) -> str:
|
|
145
|
+
return f"PARENT:\n{parent_text}\n\nCHILD:\n{child_text}"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def is_cached(prompt: str, content: str, model: str = DEFAULT_MODEL) -> bool:
|
|
149
|
+
"""True when this grade can be replayed without an API call."""
|
|
150
|
+
return _cache_get(_cache_key(model, prompt, content)) is not None
|
|
151
|
+
|
|
152
|
+
|
|
120
153
|
def run_alignment(check_id: str, prompt: str, parent_text: str,
|
|
121
154
|
child_text: str, threshold: float = 0.7) -> CheckResult:
|
|
122
155
|
"""Advisory: does the child item genuinely fulfil the parent it links to?"""
|
|
123
|
-
|
|
124
|
-
|
|
156
|
+
return advisory(check_id, prompt, alignment_content(parent_text, child_text),
|
|
157
|
+
threshold=threshold)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Tests for the advisory grading layer: parsing, caching, and the API budget."""
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from docassert import consistency, semantic
|
|
7
|
+
from docassert.models import Item
|
|
8
|
+
from docassert.semantic import _parse_grade
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ── _parse_grade ─────────────────────────────────────────────────────────────
|
|
12
|
+
def test_parse_grade_clean_json():
|
|
13
|
+
g = _parse_grade('{"score": 0.9, "pass": true, "rationale": "solid"}')
|
|
14
|
+
assert g == {"score": 0.9, "pass": True, "rationale": "solid"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_parse_grade_tolerates_prose_and_fences():
|
|
18
|
+
g = _parse_grade('Here you go:\n```json\n{"score": 0.4, "pass": false, "rationale": "weak"}\n```')
|
|
19
|
+
assert g["score"] == 0.4 and g["pass"] is False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_parse_grade_recovers_truncated_response():
|
|
23
|
+
# The exact failure shape seen live: max_tokens cut the rationale short.
|
|
24
|
+
text = '{"score": 0.35, "pass": false, "rationale": "The child only refines the \'2 seconds\' timing aspect (backend fingerprint r'
|
|
25
|
+
g = _parse_grade(text)
|
|
26
|
+
assert g["score"] == 0.35
|
|
27
|
+
assert g["pass"] is False
|
|
28
|
+
assert g["rationale"].endswith("[truncated]")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_parse_grade_truncated_without_pass_leaves_it_to_threshold():
|
|
32
|
+
g = _parse_grade('{"score": 0.8, "ratio')
|
|
33
|
+
assert g["score"] == 0.8 and "pass" not in g
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_parse_grade_raises_on_garbage():
|
|
37
|
+
with pytest.raises(ValueError):
|
|
38
|
+
_parse_grade("I cannot grade this.")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── cache replay ─────────────────────────────────────────────────────────────
|
|
42
|
+
def _use_tmp_cache(tmp_path, monkeypatch):
|
|
43
|
+
monkeypatch.setattr(semantic, "CACHE_DIR", tmp_path / "cache")
|
|
44
|
+
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_advisory_caches_grades(tmp_path, monkeypatch):
|
|
48
|
+
_use_tmp_cache(tmp_path, monkeypatch)
|
|
49
|
+
calls = []
|
|
50
|
+
monkeypatch.setattr(semantic, "_grade", lambda p, c, m: (
|
|
51
|
+
calls.append(1) or {"score": 1.0, "pass": True, "rationale": "ok"}))
|
|
52
|
+
for _ in range(2):
|
|
53
|
+
r = semantic.advisory("chk", "prompt", "content")
|
|
54
|
+
assert r.passed and r.score == 1.0
|
|
55
|
+
assert len(calls) == 1
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_advisory_does_not_cache_failures(tmp_path, monkeypatch):
|
|
59
|
+
_use_tmp_cache(tmp_path, monkeypatch)
|
|
60
|
+
calls = []
|
|
61
|
+
|
|
62
|
+
def boom(p, c, m):
|
|
63
|
+
calls.append(1)
|
|
64
|
+
raise ValueError("no JSON in model response: ''")
|
|
65
|
+
|
|
66
|
+
monkeypatch.setattr(semantic, "_grade", boom)
|
|
67
|
+
for _ in range(2):
|
|
68
|
+
r = semantic.advisory("chk", "prompt", "content")
|
|
69
|
+
assert "unavailable" in r.detail
|
|
70
|
+
assert len(calls) == 2 # transient errors retry on the next run
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── the alignment budget spends on cache misses ──────────────────────────────
|
|
74
|
+
def _alignment_graph(n):
|
|
75
|
+
from docassert.graph import Graph
|
|
76
|
+
g = Graph()
|
|
77
|
+
g.add(Item("TST-PR-001", "TST", "PR", "the parent requirement", {},
|
|
78
|
+
"d.md", "prd", "approved", "S"))
|
|
79
|
+
for i in range(1, n + 1):
|
|
80
|
+
g.add(Item(f"TST-US-{i:03d}", "TST", "US", f"story {i}",
|
|
81
|
+
{"traces": ["TST-PR-001"]}, "d.md", "user-story",
|
|
82
|
+
"approved", "S"))
|
|
83
|
+
return g
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_alignment_budget_walks_the_graph_across_runs(tmp_path, monkeypatch):
|
|
87
|
+
_use_tmp_cache(tmp_path, monkeypatch)
|
|
88
|
+
calls = []
|
|
89
|
+
monkeypatch.setattr(semantic, "_grade", lambda p, c, m: (
|
|
90
|
+
calls.append(c) or {"score": 1.0, "pass": True, "rationale": "ok"}))
|
|
91
|
+
graph = _alignment_graph(5)
|
|
92
|
+
config = {"alignment": [{"relation": "traces", "prompt": "aligned?"}],
|
|
93
|
+
"alignment_limit": 2}
|
|
94
|
+
|
|
95
|
+
first = consistency.run_alignment_checks(graph, config)
|
|
96
|
+
assert len(calls) == 2 # budget spent on misses
|
|
97
|
+
assert any(r.check_id == "alignment-limit" and "2 of 5" in r.detail
|
|
98
|
+
for r in first)
|
|
99
|
+
|
|
100
|
+
second = consistency.run_alignment_checks(graph, config)
|
|
101
|
+
assert len(calls) == 4 # next two misses, not repeats
|
|
102
|
+
assert len(set(map(json.dumps, calls))) == 4
|
|
103
|
+
graded = [r for r in second if r.check_id.startswith("align:")]
|
|
104
|
+
assert len(graded) == 4 # 2 cached replays + 2 new
|
|
105
|
+
|
|
106
|
+
third = consistency.run_alignment_checks(graph, config)
|
|
107
|
+
assert len(calls) == 5 # the last miss
|
|
108
|
+
assert not any(r.check_id == "alignment-limit" for r in third)
|
|
109
|
+
graded = [r for r in third if r.check_id.startswith("align:")]
|
|
110
|
+
assert len(graded) == 5 # full coverage reached
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_alignment_limit_zero_grades_everything(tmp_path, monkeypatch):
|
|
114
|
+
_use_tmp_cache(tmp_path, monkeypatch)
|
|
115
|
+
calls = []
|
|
116
|
+
monkeypatch.setattr(semantic, "_grade", lambda p, c, m: (
|
|
117
|
+
calls.append(1) or {"score": 1.0, "pass": True, "rationale": "ok"}))
|
|
118
|
+
graph = _alignment_graph(4)
|
|
119
|
+
config = {"alignment": [{"relation": "traces", "prompt": "aligned?"}],
|
|
120
|
+
"alignment_limit": 0}
|
|
121
|
+
consistency.run_alignment_checks(graph, config)
|
|
122
|
+
assert len(calls) == 4
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ── typed rules: first match grades the link ─────────────────────────────────
|
|
126
|
+
def _typed_graph():
|
|
127
|
+
from docassert.graph import Graph
|
|
128
|
+
g = Graph()
|
|
129
|
+
g.add(Item("TST-BR-001", "TST", "BR", "the business outcome", {},
|
|
130
|
+
"d.md", "brd", "approved", "S"))
|
|
131
|
+
g.add(Item("TST-PR-001", "TST", "PR", "the product mechanism",
|
|
132
|
+
{"traces": ["TST-BR-001"]}, "d.md", "prd", "approved", "S"))
|
|
133
|
+
g.add(Item("TST-US-001", "TST", "US", "the story",
|
|
134
|
+
{"traces": ["TST-PR-001"]}, "d.md", "user-story",
|
|
135
|
+
"approved", "S"))
|
|
136
|
+
return g
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
TYPED_CONFIG = {"alignment": [
|
|
140
|
+
{"relation": "traces", "child_type": "PR", "parent_type": "BR",
|
|
141
|
+
"prompt": "contribution rubric"},
|
|
142
|
+
{"relation": "traces", "prompt": "refinement rubric"},
|
|
143
|
+
], "alignment_limit": 0}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_typed_rule_grades_matching_links_first(tmp_path, monkeypatch):
|
|
147
|
+
_use_tmp_cache(tmp_path, monkeypatch)
|
|
148
|
+
prompts = []
|
|
149
|
+
monkeypatch.setattr(semantic, "_grade", lambda p, c, m: (
|
|
150
|
+
prompts.append(p) or {"score": 1.0, "pass": True, "rationale": "ok"}))
|
|
151
|
+
results = consistency.run_alignment_checks(_typed_graph(), TYPED_CONFIG)
|
|
152
|
+
graded = {r.check_id for r in results if r.check_id.startswith("align:")}
|
|
153
|
+
assert graded == {"align:TST-PR-001-traces-TST-BR-001",
|
|
154
|
+
"align:TST-US-001-traces-TST-PR-001"}
|
|
155
|
+
assert sorted(prompts) == ["contribution rubric", "refinement rubric"]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_typed_rule_does_not_double_grade(tmp_path, monkeypatch):
|
|
159
|
+
_use_tmp_cache(tmp_path, monkeypatch)
|
|
160
|
+
calls = []
|
|
161
|
+
monkeypatch.setattr(semantic, "_grade", lambda p, c, m: (
|
|
162
|
+
calls.append((p, c)) or {"score": 1.0, "pass": True, "rationale": "ok"}))
|
|
163
|
+
consistency.run_alignment_checks(_typed_graph(), TYPED_CONFIG)
|
|
164
|
+
assert len(calls) == 2 # one grade per link, despite two matching relations
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_packaged_defaults_carry_typed_rules():
|
|
168
|
+
from docassert import config as config_mod
|
|
169
|
+
cfg = config_mod.read_consistency_config()
|
|
170
|
+
rules = cfg.get("alignment", [])
|
|
171
|
+
typed = [r for r in rules if r.get("child_type") == "PR"
|
|
172
|
+
and r.get("parent_type") == "BR"]
|
|
173
|
+
assert typed and rules.index(typed[0]) < len(rules) - 1
|
|
174
|
+
assert "contribute" in typed[0]["prompt"]
|
|
175
|
+
verifies = [r for r in rules if r["relation"] == "verifies"][0]
|
|
176
|
+
assert "slice" in verifies["prompt"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/benefits-realization.criteria.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/data-migration-plan.criteria.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/raci-stakeholder.criteria.yaml
RENAMED
|
File without changes
|
{docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/release-cutover-plan.criteria.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/benefits-realization.schema.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/data-migration-plan.schema.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/post-implementation-review.schema.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/release-cutover-plan.schema.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/benefits-realization.template.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/data-migration-plan.template.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/raci-stakeholder.template.md
RENAMED
|
File without changes
|
{docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/release-cutover-plan.template.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|