docassert 0.10.0__tar.gz → 0.11.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.0/docassert.egg-info → docassert-0.11.0}/PKG-INFO +1 -1
  2. {docassert-0.10.0 → docassert-0.11.0}/docassert/__init__.py +1 -1
  3. {docassert-0.10.0 → docassert-0.11.0}/docassert/bridge/plan.py +19 -3
  4. {docassert-0.10.0 → docassert-0.11.0}/docassert/consistency.py +23 -11
  5. {docassert-0.10.0 → docassert-0.11.0}/docassert/semantic.py +43 -10
  6. {docassert-0.10.0 → docassert-0.11.0/docassert.egg-info}/PKG-INFO +1 -1
  7. {docassert-0.10.0 → docassert-0.11.0}/docassert.egg-info/SOURCES.txt +1 -0
  8. {docassert-0.10.0 → docassert-0.11.0}/tests/test_bridge.py +14 -0
  9. docassert-0.11.0/tests/test_semantic.py +122 -0
  10. {docassert-0.10.0 → docassert-0.11.0}/LICENSE +0 -0
  11. {docassert-0.10.0 → docassert-0.11.0}/NOTICE +0 -0
  12. {docassert-0.10.0 → docassert-0.11.0}/README.md +0 -0
  13. {docassert-0.10.0 → docassert-0.11.0}/docassert/__main__.py +0 -0
  14. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/consistency.yaml +0 -0
  15. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/criteria/adr.criteria.yaml +0 -0
  16. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/criteria/benefits-realization.criteria.yaml +0 -0
  17. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/criteria/brd.criteria.yaml +0 -0
  18. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/criteria/business-case.criteria.yaml +0 -0
  19. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/criteria/charter.criteria.yaml +0 -0
  20. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/criteria/data-migration-plan.criteria.yaml +0 -0
  21. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/criteria/frnfr.criteria.yaml +0 -0
  22. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/criteria/hypercare-plan.criteria.yaml +0 -0
  23. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/criteria/post-implementation-review.criteria.yaml +0 -0
  24. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/criteria/prd.criteria.yaml +0 -0
  25. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/criteria/project.criteria.yaml +0 -0
  26. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/criteria/qa-test-plan.criteria.yaml +0 -0
  27. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/criteria/raci-stakeholder.criteria.yaml +0 -0
  28. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/criteria/release-cutover-plan.criteria.yaml +0 -0
  29. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/criteria/risk-register.criteria.yaml +0 -0
  30. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/criteria/rollback-plan.criteria.yaml +0 -0
  31. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/criteria/runbook.criteria.yaml +0 -0
  32. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/criteria/status-report.criteria.yaml +0 -0
  33. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/criteria/test-cases.criteria.yaml +0 -0
  34. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/criteria/user-story.criteria.yaml +0 -0
  35. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/profiles/agile-delivery.yaml +0 -0
  36. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/profiles/lean-startup.yaml +0 -0
  37. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/profiles/regulated-industry.yaml +0 -0
  38. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/schema/adr.schema.json +0 -0
  39. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/schema/benefits-realization.schema.json +0 -0
  40. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/schema/brd.schema.json +0 -0
  41. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/schema/business-case.schema.json +0 -0
  42. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/schema/charter.schema.json +0 -0
  43. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/schema/data-migration-plan.schema.json +0 -0
  44. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/schema/frnfr.schema.json +0 -0
  45. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/schema/hypercare-plan.schema.json +0 -0
  46. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/schema/post-implementation-review.schema.json +0 -0
  47. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/schema/prd.schema.json +0 -0
  48. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/schema/project.schema.json +0 -0
  49. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/schema/qa-test-plan.schema.json +0 -0
  50. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/schema/raci-stakeholder.schema.json +0 -0
  51. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/schema/release-cutover-plan.schema.json +0 -0
  52. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/schema/risk-register.schema.json +0 -0
  53. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/schema/rollback-plan.schema.json +0 -0
  54. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/schema/runbook.schema.json +0 -0
  55. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/schema/status-report.schema.json +0 -0
  56. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/schema/test-cases.schema.json +0 -0
  57. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/schema/user-story.schema.json +0 -0
  58. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/skills/doc-to-pmo/SKILL.md +0 -0
  59. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/templates/adr.template.md +0 -0
  60. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/templates/benefits-realization.template.md +0 -0
  61. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/templates/brd.template.md +0 -0
  62. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/templates/business-case.template.md +0 -0
  63. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/templates/charter.template.md +0 -0
  64. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/templates/data-migration-plan.template.md +0 -0
  65. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/templates/frnfr.template.md +0 -0
  66. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/templates/hypercare-plan.template.md +0 -0
  67. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/templates/post-implementation-review.template.md +0 -0
  68. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/templates/prd.template.md +0 -0
  69. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/templates/project.template.md +0 -0
  70. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/templates/qa-test-plan.template.md +0 -0
  71. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/templates/raci-stakeholder.template.md +0 -0
  72. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/templates/release-cutover-plan.template.md +0 -0
  73. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/templates/risk-register.template.md +0 -0
  74. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/templates/rollback-plan.template.md +0 -0
  75. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/templates/runbook.template.md +0 -0
  76. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/templates/status-report.template.md +0 -0
  77. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/templates/test-cases.template.md +0 -0
  78. {docassert-0.10.0 → docassert-0.11.0}/docassert/_data/templates/user-story.template.md +0 -0
  79. {docassert-0.10.0 → docassert-0.11.0}/docassert/bridge/__init__.py +0 -0
  80. {docassert-0.10.0 → docassert-0.11.0}/docassert/bridge/board.py +0 -0
  81. {docassert-0.10.0 → docassert-0.11.0}/docassert/bridge/gh.py +0 -0
  82. {docassert-0.10.0 → docassert-0.11.0}/docassert/bridge/ops.py +0 -0
  83. {docassert-0.10.0 → docassert-0.11.0}/docassert/cli.py +0 -0
  84. {docassert-0.10.0 → docassert-0.11.0}/docassert/config.py +0 -0
  85. {docassert-0.10.0 → docassert-0.11.0}/docassert/extract.py +0 -0
  86. {docassert-0.10.0 → docassert-0.11.0}/docassert/graph.py +0 -0
  87. {docassert-0.10.0 → docassert-0.11.0}/docassert/loader.py +0 -0
  88. {docassert-0.10.0 → docassert-0.11.0}/docassert/models.py +0 -0
  89. {docassert-0.10.0 → docassert-0.11.0}/docassert/profiles.py +0 -0
  90. {docassert-0.10.0 → docassert-0.11.0}/docassert/projects.py +0 -0
  91. {docassert-0.10.0 → docassert-0.11.0}/docassert/report.py +0 -0
  92. {docassert-0.10.0 → docassert-0.11.0}/docassert/rtm.py +0 -0
  93. {docassert-0.10.0 → docassert-0.11.0}/docassert/scaffold.py +0 -0
  94. {docassert-0.10.0 → docassert-0.11.0}/docassert/status/__init__.py +0 -0
  95. {docassert-0.10.0 → docassert-0.11.0}/docassert/status/derive.py +0 -0
  96. {docassert-0.10.0 → docassert-0.11.0}/docassert/status/render.py +0 -0
  97. {docassert-0.10.0 → docassert-0.11.0}/docassert/structural.py +0 -0
  98. {docassert-0.10.0 → docassert-0.11.0}/docassert.egg-info/dependency_links.txt +0 -0
  99. {docassert-0.10.0 → docassert-0.11.0}/docassert.egg-info/entry_points.txt +0 -0
  100. {docassert-0.10.0 → docassert-0.11.0}/docassert.egg-info/requires.txt +0 -0
  101. {docassert-0.10.0 → docassert-0.11.0}/docassert.egg-info/top_level.txt +0 -0
  102. {docassert-0.10.0 → docassert-0.11.0}/pyproject.toml +0 -0
  103. {docassert-0.10.0 → docassert-0.11.0}/setup.cfg +0 -0
  104. {docassert-0.10.0 → docassert-0.11.0}/tests/test_badge.py +0 -0
  105. {docassert-0.10.0 → docassert-0.11.0}/tests/test_config.py +0 -0
  106. {docassert-0.10.0 → docassert-0.11.0}/tests/test_consistency.py +0 -0
  107. {docassert-0.10.0 → docassert-0.11.0}/tests/test_defects.py +0 -0
  108. {docassert-0.10.0 → docassert-0.11.0}/tests/test_draft_relaxation.py +0 -0
  109. {docassert-0.10.0 → docassert-0.11.0}/tests/test_extract.py +0 -0
  110. {docassert-0.10.0 → docassert-0.11.0}/tests/test_graph.py +0 -0
  111. {docassert-0.10.0 → docassert-0.11.0}/tests/test_json_report.py +0 -0
  112. {docassert-0.10.0 → docassert-0.11.0}/tests/test_kinds_delivery.py +0 -0
  113. {docassert-0.10.0 → docassert-0.11.0}/tests/test_kinds_governance.py +0 -0
  114. {docassert-0.10.0 → docassert-0.11.0}/tests/test_kinds_operate.py +0 -0
  115. {docassert-0.10.0 → docassert-0.11.0}/tests/test_kinds_reporting.py +0 -0
  116. {docassert-0.10.0 → docassert-0.11.0}/tests/test_profiles.py +0 -0
  117. {docassert-0.10.0 → docassert-0.11.0}/tests/test_projects.py +0 -0
  118. {docassert-0.10.0 → docassert-0.11.0}/tests/test_scaffold.py +0 -0
  119. {docassert-0.10.0 → docassert-0.11.0}/tests/test_status.py +0 -0
  120. {docassert-0.10.0 → docassert-0.11.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.0
3
+ Version: 0.11.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.0"
8
+ __version__ = "0.11.0"
@@ -7,6 +7,7 @@ anywhere in the bridge, writes to documents/.
7
7
  """
8
8
  from __future__ import annotations
9
9
 
10
+ import os
10
11
  from dataclasses import dataclass, field
11
12
  from pathlib import Path
12
13
 
@@ -54,6 +55,18 @@ class BridgePlan:
54
55
  return {f.id for f in self.features} | {s.id for s in self.stories}
55
56
 
56
57
 
58
+ def _repo_rel(path: str, documents_dir: Path) -> str:
59
+ """Path relative to the docs repo root (the parent of documents_dir).
60
+
61
+ Issue bodies link Source as {docs_url}/{doc_path}, and docs_url points at
62
+ the docs repository. When CI checks that repository out into a
63
+ subdirectory, load paths carry the checkout prefix; stripping down to the
64
+ repo root keeps the links valid wherever the docs are checked out.
65
+ """
66
+ rel = os.path.relpath(path, documents_dir.parent)
67
+ return path if rel.startswith("..") else Path(rel).as_posix()
68
+
69
+
57
70
  def _title(item_text: str, limit: int = 64) -> str:
58
71
  """First sentence of the item text, trimmed for an issue title."""
59
72
  first = item_text.split(". ")[0].rstrip(".")
@@ -126,14 +139,17 @@ def build_bridge_plan(documents_dir: str | Path = "documents") -> BridgePlan:
126
139
  feat = features.get(parent.id)
127
140
  if feat is None:
128
141
  feat = Feature(id=parent.id, text=parent.text,
129
- doc_path=parent.doc_path, project=proj_id)
142
+ doc_path=_repo_rel(parent.doc_path,
143
+ documents_dir),
144
+ project=proj_id)
130
145
  feat.acceptance = [(a.id, a.text) for a in
131
146
  graph.children(parent.id, "verifies", "AC")]
132
147
  for aid, _ in feat.acceptance:
133
148
  feat.tests += [t.id for t in graph.children(aid, "tests", "TC")]
134
149
  features[parent.id] = feat
135
- feat.stories.append(Story(id=s.id, text=s.text,
136
- feature_id=parent.id, doc_path=s.doc_path))
150
+ feat.stories.append(Story(
151
+ id=s.id, text=s.text, feature_id=parent.id,
152
+ doc_path=_repo_rel(s.doc_path, documents_dir)))
137
153
  if problems:
138
154
  plan.skipped.append({"id": proj_id,
139
155
  "reason": "; ".join(problems)})
@@ -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,7 +135,9 @@ 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
 
@@ -151,20 +154,29 @@ def run_alignment_checks(graph, config) -> list[CheckResult]:
151
154
  if not edges:
152
155
  return []
153
156
 
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
157
  if not os.environ.get("ANTHROPIC_API_KEY"):
165
158
  return [CheckResult("alignment", True, False,
166
159
  f"skipped — no ANTHROPIC_API_KEY ({len(edges)} link(s) to grade)",
167
160
  kind="semantic", score=None)]
161
+
162
+ limit = int(config.get("alignment_limit", DEFAULT_ALIGNMENT_LIMIT) or 0)
163
+ note: CheckResult | None = None
164
+ if limit:
165
+ # Spend the budget on cache misses; cached grades replay for free.
166
+ fresh = [e for e in edges
167
+ if not semantic.is_cached(e[0], semantic.alignment_content(
168
+ e[1].text, e[2].text))]
169
+ if len(fresh) > limit:
170
+ over_budget = set(id(e) for e in fresh[limit:])
171
+ edges = [e for e in edges if id(e) not in over_budget]
172
+ note = CheckResult(
173
+ "alignment-limit", True, False,
174
+ f"graded {limit} of {len(fresh)} ungraded link(s) "
175
+ f"({len(edges) - limit} from cache) — raise `alignment_limit` in "
176
+ f"consistency.yaml, or persist `.docassert-cache` between runs "
177
+ f"to walk the rest",
178
+ kind="semantic", score=None)
179
+
168
180
  results = [run_alignment(f"align:{c.id}-{rel}-{p.id}", prompt, p.text, c.text)
169
181
  for prompt, p, c, rel in edges]
170
182
  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.0
3
+ Version: 0.11.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
@@ -113,6 +113,20 @@ def test_bodies_carry_marker_and_authority_note(tmp_path):
113
113
  assert f"<!-- docassert-bridge: {s.id} -->" in story_body(s, None)
114
114
 
115
115
 
116
+ def test_doc_paths_relative_to_docs_repo_root(tmp_path):
117
+ """CI checks the docs repo out into a subdirectory; Source links must not
118
+ carry that checkout prefix (they resolve against the docs repo root)."""
119
+ docs = _tree(tmp_path / "pmo")
120
+ plan = build_bridge_plan(docs)
121
+ f = plan.features[0]
122
+ assert f.doc_path == "documents/PRJ-001-TST/prd.md"
123
+ assert f.stories[0].doc_path == "documents/PRJ-001-TST/user-story.md"
124
+ body = feature_body(f, "https://github.com/o/r/blob/main")
125
+ assert ("**Source:** https://github.com/o/r/blob/main/"
126
+ "documents/PRJ-001-TST/prd.md") in body
127
+ assert "/pmo/" not in body
128
+
129
+
116
130
  # ── ops against a fake runner ────────────────────────────────────────────────
117
131
  class FakeGh:
118
132
  """Records mutations; serves canned issue lists."""
@@ -0,0 +1,122 @@
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes