feat(evidence): structure v3 domain rules for review
This commit is contained in:
@@ -707,22 +707,31 @@ def migrate_workspace_evidence(
|
||||
documents_by_id = {document.id: document for document in documents}
|
||||
if len(documents_by_id) != len(documents):
|
||||
raise EvidencePreparationError("duplicate_evidence_id")
|
||||
migrated = tuple(sorted(
|
||||
document.id for document in documents if document.schema_version in {1, 2}
|
||||
))
|
||||
unchanged = tuple(sorted(
|
||||
document.id for document in documents if document.schema_version == 3
|
||||
))
|
||||
upgraded = {
|
||||
evidence_id: document.model_copy(update={"schema_version": 3})
|
||||
for evidence_id, document in documents_by_id.items()
|
||||
}
|
||||
migrated_ids: list[str] = []
|
||||
unchanged_ids: list[str] = []
|
||||
curated_root = evidence_root / "curated"
|
||||
for evidence_id, document in upgraded.items():
|
||||
path = curated_root / document.kind / f"{evidence_id.removeprefix('evidence:')}.md"
|
||||
try:
|
||||
presentation_is_current = path.read_text(encoding="utf-8") == dump_curated_markdown(
|
||||
document,
|
||||
)
|
||||
except (OSError, UnicodeDecodeError):
|
||||
presentation_is_current = False
|
||||
target = unchanged_ids if presentation_is_current else migrated_ids
|
||||
target.append(evidence_id)
|
||||
migrated = tuple(sorted(migrated_ids))
|
||||
unchanged = tuple(sorted(unchanged_ids))
|
||||
if not migrated:
|
||||
return EvidenceMigrationReport(
|
||||
migrated=(),
|
||||
unchanged=unchanged,
|
||||
findings=validate_workspace_evidence(workspace_root).findings,
|
||||
)
|
||||
upgraded = {
|
||||
evidence_id: document.model_copy(update={"schema_version": 3})
|
||||
for evidence_id, document in documents_by_id.items()
|
||||
}
|
||||
findings = _stage_and_apply_authoring_tree(workspace_root, upgraded, manifest)
|
||||
return EvidenceMigrationReport(
|
||||
migrated=migrated,
|
||||
|
||||
@@ -319,6 +319,10 @@ _V2_EMPTY_LIST = "<!-- tht:empty-list -->"
|
||||
_V2_REVIEW_SEPARATOR = "<!-- tht:review-separator -->"
|
||||
_V2_REVIEW_FIELD = "<!-- tht:review-field -->"
|
||||
_V3_METADATA = re.compile(r"\A<!-- tht:metadata:([A-Za-z0-9+/=]+) -->\n")
|
||||
_V3_RAW_RULE = re.compile(
|
||||
r"\A<!-- tht:raw-rule:([A-Za-z0-9+/=]+) -->\n\n(.+)\Z",
|
||||
re.DOTALL,
|
||||
)
|
||||
_V3_KIND_LABELS = {
|
||||
"en": {
|
||||
"glossary": "Glossary",
|
||||
@@ -507,6 +511,72 @@ def _parse_v3_values(value: str, labels: dict[str, str]) -> dict[str, str]:
|
||||
return {key: "\n".join(lines) for key, lines in parsed.items()}
|
||||
|
||||
|
||||
def _leading_rule_label(value: str) -> tuple[str, str] | None:
|
||||
match = re.match(r"\A([^:;\n]{2,80}:)\s+(.+)\Z", value, re.DOTALL)
|
||||
if match is None:
|
||||
return None
|
||||
label = match.group(1)
|
||||
if len(label.removesuffix(":").split()) > 10:
|
||||
return None
|
||||
if label.lower() in {"http:", "https:"}:
|
||||
return None
|
||||
return label, match.group(2)
|
||||
|
||||
|
||||
def _render_v3_rule_detail(value: str) -> str:
|
||||
clauses = re.split(r"(?<=;)\s+", value)
|
||||
if len(clauses) < 3:
|
||||
return value
|
||||
rendered: list[str] = []
|
||||
for clause in clauses:
|
||||
labelled = _leading_rule_label(clause)
|
||||
if labelled is None:
|
||||
rendered.append(f"- {clause}")
|
||||
else:
|
||||
label, detail = labelled
|
||||
rendered.append(f"- **{label}** {detail}")
|
||||
return "\n".join(rendered)
|
||||
|
||||
|
||||
def _render_v3_rule_presentation(value: str) -> str:
|
||||
if re.search(r"(?m)^\s*(?:[-*+] |#{1,6} |```|>)", value):
|
||||
return value
|
||||
rendered: list[str] = []
|
||||
for paragraph in re.split(r"\n\s*\n", value):
|
||||
sentences = re.split(
|
||||
r"(?<=[.!?])\s+(?=[A-ZÀ-ÖØ-Þ0-9(`])",
|
||||
paragraph,
|
||||
)
|
||||
for sentence in sentences:
|
||||
labelled = _leading_rule_label(sentence)
|
||||
if labelled is None:
|
||||
rendered.append(_render_v3_rule_detail(sentence))
|
||||
continue
|
||||
label, detail = labelled
|
||||
rendered.append(f"### {label}\n\n{_render_v3_rule_detail(detail)}")
|
||||
return "\n\n".join(rendered)
|
||||
|
||||
|
||||
def _render_v3_rule(value: str) -> str:
|
||||
if "<!-- tht:raw-rule:" in value:
|
||||
raise ValueError("curated evidence rule contains a reserved marker")
|
||||
encoded = base64.b64encode(value.encode("utf-8")).decode("ascii")
|
||||
return (
|
||||
f"<!-- tht:raw-rule:{encoded} -->\n\n"
|
||||
f"{_render_v3_rule_presentation(value)}"
|
||||
)
|
||||
|
||||
|
||||
def _parse_v3_rule(value: str) -> str:
|
||||
match = _V3_RAW_RULE.fullmatch(value)
|
||||
if match is None:
|
||||
return value
|
||||
try:
|
||||
return base64.b64decode(match.group(1), validate=True).decode("utf-8")
|
||||
except (binascii.Error, UnicodeDecodeError, ValueError) as error:
|
||||
raise ValueError("curated evidence v3 rule metadata is malformed") from error
|
||||
|
||||
|
||||
def _render_v2_payload(value: CuratedEvidence, labels: dict[str, str]) -> list[str]:
|
||||
payload = value.payload
|
||||
if value.kind == "glossary":
|
||||
@@ -568,6 +638,12 @@ def _render_v2_payload(value: CuratedEvidence, labels: dict[str, str]) -> list[s
|
||||
|
||||
|
||||
def _render_v3_payload(value: CuratedEvidence, labels: dict[str, str]) -> list[str]:
|
||||
if value.kind == "domain":
|
||||
return [_render_v2_field(
|
||||
"rule",
|
||||
labels["rule"],
|
||||
_render_v3_rule(value.payload.rule),
|
||||
)]
|
||||
if value.kind != "enum":
|
||||
return _render_v2_payload(value, labels)
|
||||
payload = value.payload
|
||||
@@ -705,14 +781,22 @@ def _render_v3_metadata(value: CuratedEvidence) -> str:
|
||||
return f"<!-- tht:metadata:{encoded} -->"
|
||||
|
||||
|
||||
def _render_v3_body(value: CuratedEvidence) -> str:
|
||||
def _render_v3_body(value: CuratedEvidence, *, structured_domain_rule: bool = True) -> str:
|
||||
if "\n" in value.title:
|
||||
raise ValueError("curated evidence title must be single-line in v3")
|
||||
labels = _v2_labels(value.language)
|
||||
fields = [
|
||||
_render_v3_overview(value, labels),
|
||||
_render_v3_scope(value, labels),
|
||||
*_render_v3_payload(value, labels),
|
||||
*(
|
||||
_render_v3_payload(value, labels)
|
||||
if structured_domain_rule
|
||||
else (
|
||||
[_render_v2_field("rule", labels["rule"], value.payload.rule)]
|
||||
if value.kind == "domain"
|
||||
else _render_v3_payload(value, labels)
|
||||
)
|
||||
),
|
||||
_render_v2_field(
|
||||
"supporting_excerpts",
|
||||
labels["supporting_excerpts"],
|
||||
@@ -827,6 +911,8 @@ def _parse_v2_payload(
|
||||
def _parse_v3_payload(
|
||||
kind: str, fields: dict[str, str], labels: dict[str, str],
|
||||
) -> tuple[dict, set[str]]:
|
||||
if kind == "domain":
|
||||
return {"rule": _parse_v3_rule(fields.get("rule", ""))}, {"rule"}
|
||||
if kind != "enum":
|
||||
return _parse_v2_payload(kind, fields, labels)
|
||||
return {
|
||||
@@ -985,7 +1071,9 @@ def parse_curated_markdown(text: str, *, path: Path | None = None) -> CuratedEvi
|
||||
data["payload"] = data.pop(kind, None)
|
||||
evidence = CuratedEvidence.model_validate(data)
|
||||
if evidence.schema_version == 3 and dump_curated_markdown(evidence) != text:
|
||||
raise ValueError("curated evidence v3 presentation is not canonical")
|
||||
legacy = f"{_render_v3_metadata(evidence)}\n{_render_v3_body(evidence, structured_domain_rule=False)}"
|
||||
if legacy != text:
|
||||
raise ValueError("curated evidence v3 presentation is not canonical")
|
||||
if path is not None:
|
||||
_validate_kind_directory(path, evidence.kind)
|
||||
return evidence
|
||||
|
||||
Reference in New Issue
Block a user