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.
Files changed (120) hide show
  1. {docassert-0.10.1/docassert.egg-info → docassert-0.12.0}/PKG-INFO +1 -1
  2. {docassert-0.10.1 → docassert-0.12.0}/docassert/__init__.py +1 -1
  3. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/consistency.yaml +16 -2
  4. {docassert-0.10.1 → docassert-0.12.0}/docassert/consistency.py +41 -13
  5. {docassert-0.10.1 → docassert-0.12.0}/docassert/semantic.py +43 -10
  6. {docassert-0.10.1 → docassert-0.12.0/docassert.egg-info}/PKG-INFO +1 -1
  7. {docassert-0.10.1 → docassert-0.12.0}/docassert.egg-info/SOURCES.txt +1 -0
  8. docassert-0.12.0/tests/test_semantic.py +176 -0
  9. {docassert-0.10.1 → docassert-0.12.0}/LICENSE +0 -0
  10. {docassert-0.10.1 → docassert-0.12.0}/NOTICE +0 -0
  11. {docassert-0.10.1 → docassert-0.12.0}/README.md +0 -0
  12. {docassert-0.10.1 → docassert-0.12.0}/docassert/__main__.py +0 -0
  13. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/adr.criteria.yaml +0 -0
  14. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/benefits-realization.criteria.yaml +0 -0
  15. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/brd.criteria.yaml +0 -0
  16. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/business-case.criteria.yaml +0 -0
  17. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/charter.criteria.yaml +0 -0
  18. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/data-migration-plan.criteria.yaml +0 -0
  19. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/frnfr.criteria.yaml +0 -0
  20. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/hypercare-plan.criteria.yaml +0 -0
  21. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/post-implementation-review.criteria.yaml +0 -0
  22. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/prd.criteria.yaml +0 -0
  23. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/project.criteria.yaml +0 -0
  24. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/qa-test-plan.criteria.yaml +0 -0
  25. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/raci-stakeholder.criteria.yaml +0 -0
  26. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/release-cutover-plan.criteria.yaml +0 -0
  27. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/risk-register.criteria.yaml +0 -0
  28. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/rollback-plan.criteria.yaml +0 -0
  29. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/runbook.criteria.yaml +0 -0
  30. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/status-report.criteria.yaml +0 -0
  31. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/test-cases.criteria.yaml +0 -0
  32. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/criteria/user-story.criteria.yaml +0 -0
  33. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/profiles/agile-delivery.yaml +0 -0
  34. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/profiles/lean-startup.yaml +0 -0
  35. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/profiles/regulated-industry.yaml +0 -0
  36. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/adr.schema.json +0 -0
  37. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/benefits-realization.schema.json +0 -0
  38. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/brd.schema.json +0 -0
  39. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/business-case.schema.json +0 -0
  40. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/charter.schema.json +0 -0
  41. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/data-migration-plan.schema.json +0 -0
  42. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/frnfr.schema.json +0 -0
  43. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/hypercare-plan.schema.json +0 -0
  44. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/post-implementation-review.schema.json +0 -0
  45. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/prd.schema.json +0 -0
  46. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/project.schema.json +0 -0
  47. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/qa-test-plan.schema.json +0 -0
  48. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/raci-stakeholder.schema.json +0 -0
  49. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/release-cutover-plan.schema.json +0 -0
  50. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/risk-register.schema.json +0 -0
  51. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/rollback-plan.schema.json +0 -0
  52. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/runbook.schema.json +0 -0
  53. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/status-report.schema.json +0 -0
  54. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/test-cases.schema.json +0 -0
  55. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/schema/user-story.schema.json +0 -0
  56. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/skills/doc-to-pmo/SKILL.md +0 -0
  57. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/adr.template.md +0 -0
  58. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/benefits-realization.template.md +0 -0
  59. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/brd.template.md +0 -0
  60. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/business-case.template.md +0 -0
  61. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/charter.template.md +0 -0
  62. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/data-migration-plan.template.md +0 -0
  63. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/frnfr.template.md +0 -0
  64. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/hypercare-plan.template.md +0 -0
  65. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/post-implementation-review.template.md +0 -0
  66. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/prd.template.md +0 -0
  67. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/project.template.md +0 -0
  68. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/qa-test-plan.template.md +0 -0
  69. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/raci-stakeholder.template.md +0 -0
  70. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/release-cutover-plan.template.md +0 -0
  71. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/risk-register.template.md +0 -0
  72. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/rollback-plan.template.md +0 -0
  73. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/runbook.template.md +0 -0
  74. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/status-report.template.md +0 -0
  75. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/test-cases.template.md +0 -0
  76. {docassert-0.10.1 → docassert-0.12.0}/docassert/_data/templates/user-story.template.md +0 -0
  77. {docassert-0.10.1 → docassert-0.12.0}/docassert/bridge/__init__.py +0 -0
  78. {docassert-0.10.1 → docassert-0.12.0}/docassert/bridge/board.py +0 -0
  79. {docassert-0.10.1 → docassert-0.12.0}/docassert/bridge/gh.py +0 -0
  80. {docassert-0.10.1 → docassert-0.12.0}/docassert/bridge/ops.py +0 -0
  81. {docassert-0.10.1 → docassert-0.12.0}/docassert/bridge/plan.py +0 -0
  82. {docassert-0.10.1 → docassert-0.12.0}/docassert/cli.py +0 -0
  83. {docassert-0.10.1 → docassert-0.12.0}/docassert/config.py +0 -0
  84. {docassert-0.10.1 → docassert-0.12.0}/docassert/extract.py +0 -0
  85. {docassert-0.10.1 → docassert-0.12.0}/docassert/graph.py +0 -0
  86. {docassert-0.10.1 → docassert-0.12.0}/docassert/loader.py +0 -0
  87. {docassert-0.10.1 → docassert-0.12.0}/docassert/models.py +0 -0
  88. {docassert-0.10.1 → docassert-0.12.0}/docassert/profiles.py +0 -0
  89. {docassert-0.10.1 → docassert-0.12.0}/docassert/projects.py +0 -0
  90. {docassert-0.10.1 → docassert-0.12.0}/docassert/report.py +0 -0
  91. {docassert-0.10.1 → docassert-0.12.0}/docassert/rtm.py +0 -0
  92. {docassert-0.10.1 → docassert-0.12.0}/docassert/scaffold.py +0 -0
  93. {docassert-0.10.1 → docassert-0.12.0}/docassert/status/__init__.py +0 -0
  94. {docassert-0.10.1 → docassert-0.12.0}/docassert/status/derive.py +0 -0
  95. {docassert-0.10.1 → docassert-0.12.0}/docassert/status/render.py +0 -0
  96. {docassert-0.10.1 → docassert-0.12.0}/docassert/structural.py +0 -0
  97. {docassert-0.10.1 → docassert-0.12.0}/docassert.egg-info/dependency_links.txt +0 -0
  98. {docassert-0.10.1 → docassert-0.12.0}/docassert.egg-info/entry_points.txt +0 -0
  99. {docassert-0.10.1 → docassert-0.12.0}/docassert.egg-info/requires.txt +0 -0
  100. {docassert-0.10.1 → docassert-0.12.0}/docassert.egg-info/top_level.txt +0 -0
  101. {docassert-0.10.1 → docassert-0.12.0}/pyproject.toml +0 -0
  102. {docassert-0.10.1 → docassert-0.12.0}/setup.cfg +0 -0
  103. {docassert-0.10.1 → docassert-0.12.0}/tests/test_badge.py +0 -0
  104. {docassert-0.10.1 → docassert-0.12.0}/tests/test_bridge.py +0 -0
  105. {docassert-0.10.1 → docassert-0.12.0}/tests/test_config.py +0 -0
  106. {docassert-0.10.1 → docassert-0.12.0}/tests/test_consistency.py +0 -0
  107. {docassert-0.10.1 → docassert-0.12.0}/tests/test_defects.py +0 -0
  108. {docassert-0.10.1 → docassert-0.12.0}/tests/test_draft_relaxation.py +0 -0
  109. {docassert-0.10.1 → docassert-0.12.0}/tests/test_extract.py +0 -0
  110. {docassert-0.10.1 → docassert-0.12.0}/tests/test_graph.py +0 -0
  111. {docassert-0.10.1 → docassert-0.12.0}/tests/test_json_report.py +0 -0
  112. {docassert-0.10.1 → docassert-0.12.0}/tests/test_kinds_delivery.py +0 -0
  113. {docassert-0.10.1 → docassert-0.12.0}/tests/test_kinds_governance.py +0 -0
  114. {docassert-0.10.1 → docassert-0.12.0}/tests/test_kinds_operate.py +0 -0
  115. {docassert-0.10.1 → docassert-0.12.0}/tests/test_kinds_reporting.py +0 -0
  116. {docassert-0.10.1 → docassert-0.12.0}/tests/test_profiles.py +0 -0
  117. {docassert-0.10.1 → docassert-0.12.0}/tests/test_projects.py +0 -0
  118. {docassert-0.10.1 → docassert-0.12.0}/tests/test_scaffold.py +0 -0
  119. {docassert-0.10.1 → docassert-0.12.0}/tests/test_status.py +0 -0
  120. {docassert-0.10.1 → docassert-0.12.0}/tests/test_structural.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docassert
3
- Version: 0.10.1
3
+ Version: 0.12.0
4
4
  Summary: Unit testing for business documents — validate structured Markdown docs against a configurable audit standard.
5
5
  Author: C4G Enterprises Inc.
6
6
  License: Apache-2.0
@@ -5,4 +5,4 @@ standard: deterministic structural checks that gate a merge, plus optional
5
5
  AI-graded semantic checks that advise.
6
6
  """
7
7
 
8
- __version__ = "0.10.1"
8
+ __version__ = "0.12.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 actually verifies the requirement
51
- it targets: testable, and covering the requirement's intent.
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 not None:
149
- edges.append((prompt, parent, child, relation))
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("DOCUNIT_MODEL", "claude-sonnet-5")
17
- CACHE_DIR = Path(os.environ.get("DOCUNIT_CACHE", ".docassert-cache"))
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=400,
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
- # tolerate models that wrap JSON in prose or fences
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
- content = f"PARENT:\n{parent_text}\n\nCHILD:\n{child_text}"
124
- return advisory(check_id, prompt, content, threshold=threshold)
156
+ return advisory(check_id, prompt, alignment_content(parent_text, child_text),
157
+ threshold=threshold)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docassert
3
- Version: 0.10.1
3
+ Version: 0.12.0
4
4
  Summary: Unit testing for business documents — validate structured Markdown docs against a configurable audit standard.
5
5
  Author: C4G Enterprises Inc.
6
6
  License: Apache-2.0
@@ -113,5 +113,6 @@ tests/test_kinds_reporting.py
113
113
  tests/test_profiles.py
114
114
  tests/test_projects.py
115
115
  tests/test_scaffold.py
116
+ tests/test_semantic.py
116
117
  tests/test_status.py
117
118
  tests/test_structural.py
@@ -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
File without changes
File without changes