docassert 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.
- docassert/__init__.py +8 -0
- docassert/__main__.py +6 -0
- docassert/_data/consistency.yaml +51 -0
- docassert/_data/criteria/adr.criteria.yaml +36 -0
- docassert/_data/criteria/benefits-realization.criteria.yaml +30 -0
- docassert/_data/criteria/brd.criteria.yaml +30 -0
- docassert/_data/criteria/business-case.criteria.yaml +23 -0
- docassert/_data/criteria/charter.criteria.yaml +73 -0
- docassert/_data/criteria/data-migration-plan.criteria.yaml +28 -0
- docassert/_data/criteria/frnfr.criteria.yaml +31 -0
- docassert/_data/criteria/hypercare-plan.criteria.yaml +27 -0
- docassert/_data/criteria/post-implementation-review.criteria.yaml +24 -0
- docassert/_data/criteria/prd.criteria.yaml +31 -0
- docassert/_data/criteria/project.criteria.yaml +32 -0
- docassert/_data/criteria/qa-test-plan.criteria.yaml +27 -0
- docassert/_data/criteria/raci-stakeholder.criteria.yaml +24 -0
- docassert/_data/criteria/release-cutover-plan.criteria.yaml +30 -0
- docassert/_data/criteria/risk-register.criteria.yaml +32 -0
- docassert/_data/criteria/rollback-plan.criteria.yaml +29 -0
- docassert/_data/criteria/runbook.criteria.yaml +30 -0
- docassert/_data/criteria/status-report.criteria.yaml +26 -0
- docassert/_data/criteria/test-cases.criteria.yaml +28 -0
- docassert/_data/criteria/user-story.criteria.yaml +32 -0
- docassert/_data/profiles/agile-delivery.yaml +20 -0
- docassert/_data/profiles/lean-startup.yaml +19 -0
- docassert/_data/profiles/regulated-industry.yaml +31 -0
- docassert/_data/schema/adr.schema.json +45 -0
- docassert/_data/schema/benefits-realization.schema.json +45 -0
- docassert/_data/schema/brd.schema.json +45 -0
- docassert/_data/schema/business-case.schema.json +45 -0
- docassert/_data/schema/charter.schema.json +84 -0
- docassert/_data/schema/data-migration-plan.schema.json +45 -0
- docassert/_data/schema/frnfr.schema.json +45 -0
- docassert/_data/schema/hypercare-plan.schema.json +45 -0
- docassert/_data/schema/post-implementation-review.schema.json +45 -0
- docassert/_data/schema/prd.schema.json +45 -0
- docassert/_data/schema/project.schema.json +32 -0
- docassert/_data/schema/qa-test-plan.schema.json +45 -0
- docassert/_data/schema/raci-stakeholder.schema.json +45 -0
- docassert/_data/schema/release-cutover-plan.schema.json +45 -0
- docassert/_data/schema/risk-register.schema.json +45 -0
- docassert/_data/schema/rollback-plan.schema.json +45 -0
- docassert/_data/schema/runbook.schema.json +45 -0
- docassert/_data/schema/status-report.schema.json +58 -0
- docassert/_data/schema/test-cases.schema.json +45 -0
- docassert/_data/schema/user-story.schema.json +45 -0
- docassert/_data/templates/adr.template.md +17 -0
- docassert/_data/templates/benefits-realization.template.md +25 -0
- docassert/_data/templates/brd.template.md +22 -0
- docassert/_data/templates/business-case.template.md +27 -0
- docassert/_data/templates/charter.template.md +46 -0
- docassert/_data/templates/data-migration-plan.template.md +35 -0
- docassert/_data/templates/frnfr.template.md +19 -0
- docassert/_data/templates/hypercare-plan.template.md +29 -0
- docassert/_data/templates/post-implementation-review.template.md +31 -0
- docassert/_data/templates/prd.template.md +23 -0
- docassert/_data/templates/project.template.md +17 -0
- docassert/_data/templates/qa-test-plan.template.md +31 -0
- docassert/_data/templates/raci-stakeholder.template.md +21 -0
- docassert/_data/templates/release-cutover-plan.template.md +28 -0
- docassert/_data/templates/risk-register.template.md +18 -0
- docassert/_data/templates/rollback-plan.template.md +24 -0
- docassert/_data/templates/runbook.template.md +28 -0
- docassert/_data/templates/status-report.template.md +27 -0
- docassert/_data/templates/test-cases.template.md +17 -0
- docassert/_data/templates/user-story.template.md +17 -0
- docassert/cli.py +291 -0
- docassert/config.py +104 -0
- docassert/consistency.py +167 -0
- docassert/graph.py +68 -0
- docassert/loader.py +116 -0
- docassert/models.py +99 -0
- docassert/profiles.py +111 -0
- docassert/projects.py +49 -0
- docassert/report.py +83 -0
- docassert/rtm.py +70 -0
- docassert/semantic.py +124 -0
- docassert/status.py +538 -0
- docassert/structural.py +406 -0
- docassert-0.1.0.dist-info/METADATA +125 -0
- docassert-0.1.0.dist-info/RECORD +86 -0
- docassert-0.1.0.dist-info/WHEEL +5 -0
- docassert-0.1.0.dist-info/entry_points.txt +2 -0
- docassert-0.1.0.dist-info/licenses/LICENSE +201 -0
- docassert-0.1.0.dist-info/licenses/NOTICE +4 -0
- docassert-0.1.0.dist-info/top_level.txt +1 -0
docassert/report.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Render check results as console text, PR-comment markdown, or JUnit XML."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import xml.etree.ElementTree as ET
|
|
5
|
+
from xml.dom import minidom
|
|
6
|
+
|
|
7
|
+
from .models import CheckResult
|
|
8
|
+
|
|
9
|
+
_TICK = "✓"
|
|
10
|
+
_CROSS = "✗"
|
|
11
|
+
_DASH = "—"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _mark(r: CheckResult) -> str:
|
|
15
|
+
if r.kind == "semantic" and r.score is None:
|
|
16
|
+
return "○" # ○ skipped/unavailable
|
|
17
|
+
return _TICK if r.passed else _CROSS
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def console(results_by_doc: dict[str, list[CheckResult]]) -> str:
|
|
21
|
+
lines: list[str] = []
|
|
22
|
+
for path, results in results_by_doc.items():
|
|
23
|
+
lines.append(f"\n{path}")
|
|
24
|
+
for r in results:
|
|
25
|
+
tier = "structural" if r.kind == "structural" else "advisory "
|
|
26
|
+
gate = "BLOCK" if r.is_blocking_failure else " "
|
|
27
|
+
score = f" [{r.score:.2f}]" if r.score is not None else ""
|
|
28
|
+
lines.append(f" {_mark(r)} {gate} {tier} {r.check_id}{score}: {r.detail}")
|
|
29
|
+
return "\n".join(lines)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def summary_line(results_by_doc: dict[str, list[CheckResult]]) -> str:
|
|
33
|
+
blocking = sum(1 for rs in results_by_doc.values() for r in rs if r.is_blocking_failure)
|
|
34
|
+
docs = len(results_by_doc)
|
|
35
|
+
if blocking:
|
|
36
|
+
return f"{_CROSS} {blocking} blocking failure(s) across {docs} document(s) {_DASH} merge blocked."
|
|
37
|
+
return f"{_TICK} All structural checks passed across {docs} document(s) {_DASH} clear to merge."
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def markdown(results_by_doc: dict[str, list[CheckResult]],
|
|
41
|
+
title: str = "docassert audit") -> str:
|
|
42
|
+
"""PR-comment body."""
|
|
43
|
+
out = [f"## {title}", "", summary_line(results_by_doc), ""]
|
|
44
|
+
for path, results in results_by_doc.items():
|
|
45
|
+
out.append(f"### `{path}`")
|
|
46
|
+
out.append("")
|
|
47
|
+
out.append("| | Check | Tier | Result |")
|
|
48
|
+
out.append("|---|---|---|---|")
|
|
49
|
+
for r in results:
|
|
50
|
+
tier = "structural (blocking)" if r.kind == "structural" else "AI advisory"
|
|
51
|
+
score = f" · score {r.score:.2f}" if r.score is not None else ""
|
|
52
|
+
if r.kind == "semantic" and r.score is None:
|
|
53
|
+
emoji = "⚪" # advisory check skipped/unavailable
|
|
54
|
+
elif r.passed:
|
|
55
|
+
emoji = "🟢"
|
|
56
|
+
else:
|
|
57
|
+
emoji = "🔴"
|
|
58
|
+
out.append(f"| {emoji} | `{r.check_id}` | {tier} | {r.detail}{score} |")
|
|
59
|
+
out.append("")
|
|
60
|
+
out.append("<sub>Structural checks block the merge. AI advisory checks inform "
|
|
61
|
+
"reviewers but never block. Configure criteria in `criteria/`.</sub>")
|
|
62
|
+
return "\n".join(out)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def junit(results_by_doc: dict[str, list[CheckResult]]) -> str:
|
|
66
|
+
total = sum(len(rs) for rs in results_by_doc.values())
|
|
67
|
+
failures = sum(1 for rs in results_by_doc.values()
|
|
68
|
+
for r in rs if r.is_blocking_failure)
|
|
69
|
+
suites = ET.Element("testsuites", tests=str(total), failures=str(failures))
|
|
70
|
+
for path, results in results_by_doc.items():
|
|
71
|
+
suite = ET.SubElement(suites, "testsuite", name=path,
|
|
72
|
+
tests=str(len(results)),
|
|
73
|
+
failures=str(sum(1 for r in results if r.is_blocking_failure)))
|
|
74
|
+
for r in results:
|
|
75
|
+
case = ET.SubElement(suite, "testcase",
|
|
76
|
+
classname=f"{path}:{r.kind}", name=r.check_id)
|
|
77
|
+
if r.is_blocking_failure:
|
|
78
|
+
ET.SubElement(case, "failure", message=r.detail).text = r.detail
|
|
79
|
+
elif r.kind == "semantic" and not r.passed:
|
|
80
|
+
# advisory failures surface as skipped, never as build failures
|
|
81
|
+
ET.SubElement(case, "skipped", message=r.detail)
|
|
82
|
+
xml = ET.tostring(suites, encoding="unicode")
|
|
83
|
+
return minidom.parseString(xml).toprettyxml(indent=" ")
|
docassert/rtm.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Generate a Requirements Traceability Matrix from the item graph.
|
|
2
|
+
|
|
3
|
+
Derived, never authored: it walks BR → PR → FR/NFR → AC → TC and renders the
|
|
4
|
+
coverage as a table, so gaps are visible at a glance.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import csv
|
|
9
|
+
import io
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _cell(ids: set[str]) -> str:
|
|
13
|
+
return ", ".join(sorted(ids)) if ids else "—"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_rows(graph, project: str | None = None) -> list[dict]:
|
|
17
|
+
rows: list[dict] = []
|
|
18
|
+
brs = graph.by_type.get("BR", [])
|
|
19
|
+
if project:
|
|
20
|
+
brs = [b for b in brs if b.project == project]
|
|
21
|
+
for br in sorted(brs, key=lambda i: i.id):
|
|
22
|
+
prs = graph.children(br.id, "traces", "PR")
|
|
23
|
+
fr_ids, ac_ids, tc_ids = set(), set(), set()
|
|
24
|
+
for pr in prs:
|
|
25
|
+
for src in graph.children(pr.id, "traces"):
|
|
26
|
+
if src.type in {"FR", "NFR"}:
|
|
27
|
+
fr_ids.add(src.id)
|
|
28
|
+
for ac in graph.children(pr.id, "verifies", "AC"):
|
|
29
|
+
ac_ids.add(ac.id)
|
|
30
|
+
for tc in graph.children(ac.id, "tests", "TC"):
|
|
31
|
+
tc_ids.add(tc.id)
|
|
32
|
+
rows.append({
|
|
33
|
+
"BR": br.id,
|
|
34
|
+
"PR": {p.id for p in prs},
|
|
35
|
+
"FR/NFR": fr_ids,
|
|
36
|
+
"AC": ac_ids,
|
|
37
|
+
"TC": tc_ids,
|
|
38
|
+
})
|
|
39
|
+
return rows
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_COLS = ["BR", "PR", "FR/NFR", "AC", "TC"]
|
|
43
|
+
_HEADERS = ["Business Req", "Product Req", "Functional / NFR",
|
|
44
|
+
"Acceptance Criteria", "Test Cases"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def render_markdown(graph, project: str | None = None) -> str:
|
|
48
|
+
rows = build_rows(graph, project)
|
|
49
|
+
title = f"# Requirements Traceability Matrix — {project}" if project else "# Requirements Traceability Matrix"
|
|
50
|
+
out = [title, ""]
|
|
51
|
+
out.append("| " + " | ".join(_HEADERS) + " |")
|
|
52
|
+
out.append("|" + "|".join(["---"] * len(_HEADERS)) + "|")
|
|
53
|
+
for r in rows:
|
|
54
|
+
out.append("| " + " | ".join(
|
|
55
|
+
[r["BR"]] + [_cell(r[c]) for c in _COLS[1:]]) + " |")
|
|
56
|
+
uncovered = [r["BR"] for r in rows if not r["PR"]]
|
|
57
|
+
out += ["",
|
|
58
|
+
f"_{len(rows)} business requirement(s); "
|
|
59
|
+
f"{len(uncovered)} with no product requirement"
|
|
60
|
+
+ (": " + ", ".join(uncovered) if uncovered else "") + "._"]
|
|
61
|
+
return "\n".join(out) + "\n"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def render_csv(graph, project: str | None = None) -> str:
|
|
65
|
+
buf = io.StringIO()
|
|
66
|
+
writer = csv.writer(buf)
|
|
67
|
+
writer.writerow(_HEADERS)
|
|
68
|
+
for r in build_rows(graph, project):
|
|
69
|
+
writer.writerow([r["BR"]] + [_cell(r[c]) for c in _COLS[1:]])
|
|
70
|
+
return buf.getvalue()
|
docassert/semantic.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""AI-graded semantic checks. These are ADVISORY — they never block a merge.
|
|
2
|
+
|
|
3
|
+
Each check asks a model to score one rubric criterion against the document and
|
|
4
|
+
return structured JSON: {score: 0..1, pass: bool, rationale: str}. Results are
|
|
5
|
+
cached by content hash so unchanged documents are not re-billed.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .models import CheckResult, Document
|
|
15
|
+
|
|
16
|
+
DEFAULT_MODEL = os.environ.get("DOCUNIT_MODEL", "claude-sonnet-5")
|
|
17
|
+
CACHE_DIR = Path(os.environ.get("DOCUNIT_CACHE", ".docassert-cache"))
|
|
18
|
+
|
|
19
|
+
_SYSTEM = (
|
|
20
|
+
"You are a meticulous document auditor. You are given one audit criterion "
|
|
21
|
+
"and a business document. Judge only that criterion. Respond with a single "
|
|
22
|
+
"JSON object and nothing else: "
|
|
23
|
+
'{"score": <number 0..1>, "pass": <true|false>, "rationale": "<one or two '
|
|
24
|
+
'sentences citing specifics from the document>"}.'
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _cache_key(model: str, prompt: str, content: str) -> str:
|
|
29
|
+
h = hashlib.sha256()
|
|
30
|
+
h.update(model.encode())
|
|
31
|
+
h.update(b"\x00")
|
|
32
|
+
h.update(prompt.encode())
|
|
33
|
+
h.update(b"\x00")
|
|
34
|
+
h.update(content.encode())
|
|
35
|
+
return h.hexdigest()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _cache_get(key: str) -> dict | None:
|
|
39
|
+
f = CACHE_DIR / f"{key}.json"
|
|
40
|
+
if f.exists():
|
|
41
|
+
try:
|
|
42
|
+
return json.loads(f.read_text())
|
|
43
|
+
except (OSError, json.JSONDecodeError):
|
|
44
|
+
return None
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _cache_put(key: str, value: dict) -> None:
|
|
49
|
+
try:
|
|
50
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
(CACHE_DIR / f"{key}.json").write_text(json.dumps(value))
|
|
52
|
+
except OSError:
|
|
53
|
+
pass # caching is best-effort
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _grade(prompt: str, content: str, model: str) -> dict:
|
|
57
|
+
"""Call the Anthropic API and return the parsed JSON grade."""
|
|
58
|
+
import anthropic # imported lazily so structural-only runs need no dependency
|
|
59
|
+
|
|
60
|
+
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
|
|
61
|
+
# Note: newer Claude models deprecate the `temperature` parameter, so we do
|
|
62
|
+
# not set it. Grading is kept consistent by the strict JSON rubric instead.
|
|
63
|
+
message = client.messages.create(
|
|
64
|
+
model=model,
|
|
65
|
+
max_tokens=400,
|
|
66
|
+
system=_SYSTEM,
|
|
67
|
+
messages=[{
|
|
68
|
+
"role": "user",
|
|
69
|
+
"content": f"AUDIT CRITERION:\n{prompt}\n\nDOCUMENT:\n{content}",
|
|
70
|
+
}],
|
|
71
|
+
)
|
|
72
|
+
text = "".join(block.text for block in message.content
|
|
73
|
+
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])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def advisory(check_id: str, prompt: str, content: str,
|
|
82
|
+
threshold: float = 0.7, model: str = DEFAULT_MODEL) -> CheckResult:
|
|
83
|
+
"""Grade one rubric prompt against `content`. Always advisory (non-blocking).
|
|
84
|
+
|
|
85
|
+
Fails safe: if the key is missing or the model errors, returns a skipped/
|
|
86
|
+
unavailable result rather than raising, so it can never block a merge.
|
|
87
|
+
"""
|
|
88
|
+
if not os.environ.get("ANTHROPIC_API_KEY"):
|
|
89
|
+
return CheckResult(check_id, True, False,
|
|
90
|
+
"skipped — no ANTHROPIC_API_KEY (advisory only)",
|
|
91
|
+
kind="semantic", score=None)
|
|
92
|
+
|
|
93
|
+
key = _cache_key(model, prompt, content)
|
|
94
|
+
grade = _cache_get(key)
|
|
95
|
+
if grade is None:
|
|
96
|
+
try:
|
|
97
|
+
grade = _grade(prompt, content, model)
|
|
98
|
+
_cache_put(key, grade)
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
return CheckResult(check_id, True, False,
|
|
101
|
+
f"advisory check unavailable: {exc}",
|
|
102
|
+
kind="semantic", score=None)
|
|
103
|
+
|
|
104
|
+
score = float(grade.get("score", 0.0))
|
|
105
|
+
passed = bool(grade.get("pass", score >= threshold))
|
|
106
|
+
rationale = str(grade.get("rationale", "")).strip()
|
|
107
|
+
return CheckResult(check_id, passed, False,
|
|
108
|
+
rationale or f"score {score:.2f} (threshold {threshold:.2f})",
|
|
109
|
+
kind="semantic", score=score)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def run_semantic(doc: Document, spec: dict, content: str) -> CheckResult:
|
|
113
|
+
return advisory(
|
|
114
|
+
spec["id"], spec.get("prompt", "").strip(), content,
|
|
115
|
+
threshold=float(spec.get("pass_threshold", 0.7)),
|
|
116
|
+
model=spec.get("model", DEFAULT_MODEL),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def run_alignment(check_id: str, prompt: str, parent_text: str,
|
|
121
|
+
child_text: str, threshold: float = 0.7) -> CheckResult:
|
|
122
|
+
"""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)
|