cloudronin's picture
push build context (uofa source + packs + space app)
a28ec65 verified
Raw
History Blame Contribute Delete
18.5 kB
"""Context extractors for interpretation prompts (spec v0.4 §4.3).
Each context bundle is the deterministic input to one LLM call. The
extractors operate on the structured outputs produced by the refactored
commands (`RulesResult.firings`, `DiffResult.weakeners_*`,
`ShaclResult.violations`) plus the package's parsed JSON-LD document.
Context dataclasses are frozen and JSON-serializable so they can be cached
by content hash (spec §4.7) without surprises.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from typing import Any
# ── Common context (every per-item prompt gets these) ──────
@dataclass(frozen=True)
class PackContext:
"""Pack metadata visible to the prompt template.
Templates reference `pack.name`, `pack.standard`, `pack.profile`. The
extractor pulls these from `pack.json` via `paths.pack_manifest`.
"""
name: str
standard: str | None = None # e.g. "ASME-VV40-2018"
profile: str | None = None # e.g. "complete" | "minimal"
@dataclass(frozen=True)
class CouContext:
"""Context-of-Use metadata when present on the package."""
name: str = ""
description: str = ""
device_class: str = ""
model_risk_level: str = ""
# ── Per-item context bundles ────────────────────────────────
@dataclass(frozen=True)
class FiringContext:
"""Context for a single rule firing (spec §4.3 — rules / check).
Two evidence-related fields populated by Round 1's enriched extractor:
- `affected_evidence`: list of resolved-node summary dicts, one per
`affected_node` IRI in the firing. Each summary carries `iri`, `kind`
(e.g. "CredibilityFactor"), `label` (the human-readable name like
"Use error" for a credibility factor), `status`, `levels`, and
optional `description`. The prompt template iterates this list to
tell the model exactly *what* fired — the SME-flagged gap from
Round 0.
- `constituent_firings`: for COMPOUND patterns, the resolved
summaries of the constituent weakener firings that triggered the
compound. Empty for non-compound patterns.
`affected_node` (singular str) is preserved for back-compat with
pre-Round-1 callers; it carries the first IRI when multiple
affected nodes exist.
"""
pattern_id: str
severity: str
hits: int
affected_node: str = ""
description: str = ""
evidence_excerpt: str = ""
pack: PackContext | None = None
cou: CouContext | None = None
# Round 1 (P-B iteration) additions:
affected_evidence: tuple[dict, ...] = () # tuple for frozen dataclass; list-like for templates
constituent_firings: tuple[dict, ...] = ()
def to_template_vars(self) -> dict:
"""Flat namespace for Jinja2 template substitution.
Spec §6.3 namespace: `firing`, `evidence`, `pack`, `cou`. The
Round 1 prompt template iterates `firing.affected_evidence` (list
of summary dicts) and `firing.constituent_firings` (list of
compound-source summaries) directly. The legacy `evidence` scalar
remains for templates that want a synthesized excerpt.
"""
return {
"firing": {
"patternId": self.pattern_id,
"severity": self.severity,
"hits": self.hits,
"affectedNode": self.affected_node,
"description": self.description,
"affected_evidence": list(self.affected_evidence),
"constituent_firings": list(self.constituent_firings),
},
"evidence": self.evidence_excerpt,
"pack": asdict(self.pack) if self.pack else {},
"cou": asdict(self.cou) if self.cou else {},
}
@dataclass(frozen=True)
class DifferenceContext:
"""Context for a single diff difference (spec §4.3 — diff)."""
pattern_id: str
severity: str
only_in: str # "A" | "B"
cou_with: CouContext | None = None
cou_without: CouContext | None = None
description: str = "" # from the .rules file when available
pack: PackContext | None = None
def to_template_vars(self) -> dict:
return {
"difference": {
"patternId": self.pattern_id,
"severity": self.severity,
"onlyIn": self.only_in,
"description": self.description,
},
"before": asdict(self.cou_without) if self.cou_without else {},
"after": asdict(self.cou_with) if self.cou_with else {},
"pack": asdict(self.pack) if self.pack else {},
}
@dataclass(frozen=True)
class ViolationContext:
"""Context for a single SHACL violation (spec §4.3 — shacl)."""
constraint_path: str # e.g. "uofa:hasContextOfUse"
severity: str
affected_node: str
expected: str = ""
actual: str = ""
description: str = "" # constraint description / fix suggestion
pack: PackContext | None = None
def to_template_vars(self) -> dict:
return {
"violation": {
"constraint": self.constraint_path,
"severity": self.severity,
"affectedNode": self.affected_node,
"description": self.description,
},
"expected": self.expected,
"actual": self.actual,
"pack": asdict(self.pack) if self.pack else {},
}
# ── Extractors ─────────────────────────────────────────────
def extract_pack_context(pack_name: str) -> PackContext:
"""Pull pack metadata from the active pack's manifest."""
try:
from uofa_cli import paths # noqa: PLC0415
manifest = paths.pack_manifest(pack_name)
standards = manifest.get("standards") or []
return PackContext(
name=pack_name,
standard=standards[0] if standards else None,
profile=manifest.get("profile"),
)
except (FileNotFoundError, KeyError):
return PackContext(name=pack_name)
def extract_cou_context(package_doc: dict) -> CouContext:
"""Pull COU identity from a parsed UofA JSON-LD document."""
cou = package_doc.get("hasContextOfUse", {})
if isinstance(cou, str):
cou = {}
name = cou.get("name", package_doc.get("name", "")) or ""
description = cou.get("description", "") or ""
# Cheap regex parses to match diff.py's identity extraction
import re
text = f"{name} {description}"
device_class = ""
m = re.search(r"Class\s+(I{1,3}V?)", text, re.IGNORECASE)
if m:
device_class = f"Class {m.group(1)}"
model_risk_level = ""
m = re.search(r"Model Risk Level\s+(\d+)", text, re.IGNORECASE)
if m:
model_risk_level = f"MRL {m.group(1)}"
return CouContext(
name=name,
description=description,
device_class=device_class,
model_risk_level=model_risk_level,
)
def extract_firing_contexts(
firings: list[dict],
package_doc: dict,
pack_name: str,
*,
jsonld_firings: list[dict] | None = None,
individual_annotations: list[dict] | None = None,
) -> list[FiringContext]:
"""Build FiringContexts from `RulesResult.firings` (summary parse) plus
optional richer engine output for Round 1 evidence enrichment.
Two-tier inputs:
- `firings`: the summary-mode parse (patternId/severity/hits only).
Always present.
- `jsonld_firings`: from `rules.parse_firings_jsonld()`, one dict per
patternId carrying `affected_nodes` (list of IRIs) and
`escalation_sources` (for compounds). When provided, drives the
`affected_evidence` field on the resulting FiringContexts.
- `individual_annotations`: from `rules.parse_individual_annotations()`,
one dict per individual annotation keyed by blank-node `id`. Used
to resolve compound `escalation_sources` IRIs back to constituent
firings. Required when populating `constituent_firings` for any
COMPOUND-* pattern; optional otherwise.
When `jsonld_firings` is None, behaves like the pre-Round-1 extractor
(legacy path): only patternId/severity/hits/description. This keeps the
standalone `uofa explain --from-file` flow working when callers pass a
cached envelope without engine re-invocation data.
"""
from uofa_cli.commands.rules import load_pattern_descriptions # noqa: PLC0415
pack = extract_pack_context(pack_name)
cou = extract_cou_context(package_doc)
pattern_descriptions = load_pattern_descriptions(pack_name)
# Index the rich data by patternId for quick lookup against `firings`.
jsonld_by_pid: dict[str, dict] = {}
if jsonld_firings:
jsonld_by_pid = {f["patternId"]: f for f in jsonld_firings if "patternId" in f}
annotations_by_id: dict[str, dict] = {}
if individual_annotations:
annotations_by_id = {a["id"]: a for a in individual_annotations if a.get("id")}
contexts: list[FiringContext] = []
for f in firings:
pid = str(f.get("patternId", ""))
rich = jsonld_by_pid.get(pid, {})
# Description preference: per-firing (engine in jsonld may carry one),
# then pack-level from .rules file headers, then empty.
description = (
str(f.get("description", ""))
or str(rich.get("description", ""))
or pattern_descriptions.get(pid, "")
)
affected_iris: list[str] = list(rich.get("affected_nodes", []))
affected_evidence = tuple(
_summarize_node(_resolve_node_in_doc(package_doc, iri) or {"@id": iri, "id": iri})
for iri in affected_iris
)
constituent_firings: tuple[dict, ...] = ()
if pid.startswith("COMPOUND") and annotations_by_id:
constituent_firings = tuple(
_summarize_constituent(annotations_by_id[src_id], package_doc)
for src_id in rich.get("escalation_sources", [])
if src_id in annotations_by_id
)
contexts.append(FiringContext(
pattern_id=pid,
severity=str(f.get("severity", "Medium")),
hits=int(f.get("hits", 0)),
affected_node=affected_iris[0] if affected_iris else "",
description=description,
pack=pack,
cou=cou,
affected_evidence=affected_evidence,
constituent_firings=constituent_firings,
))
return contexts
# ── Round 1 (P-B iteration) helpers ────────────────────────
def _resolve_node_in_doc(package_doc: dict, iri: str) -> dict | None:
"""Walk the package JSON-LD looking for a node whose `id`/`@id` matches `iri`.
JSON-LD packages mix compact (`id`) and expanded (`@id`) forms; this
helper checks both. Returns the first match (breadth-first; depth
capped to avoid getting stuck on cyclic graph refs).
"""
if not iri:
return None
# Bounded BFS — packages are typically a few hundred nodes deep at most.
queue: list[object] = [package_doc]
seen_obj_ids: set[int] = set()
visited_count = 0
while queue and visited_count < 10_000: # hard cap; production packages stay well below
node = queue.pop(0)
visited_count += 1
oid = id(node)
if oid in seen_obj_ids:
continue
seen_obj_ids.add(oid)
if isinstance(node, dict):
nid = node.get("@id") or node.get("id")
if isinstance(nid, str) and nid == iri:
return node
queue.extend(node.values())
elif isinstance(node, list):
queue.extend(node)
return None
# Field aliases — JSON-LD packages use mixed naming. These tuples define
# the lookup priority for each summary field.
_LABEL_FIELDS = ("factorType", "name", "label", "title", "description")
_KIND_FIELDS = ("type", "@type")
_STATUS_FIELDS = ("factorStatus", "status", "decision", "outcome")
_REQUIRED_LEVEL_FIELDS = ("requiredLevel", "required_level", "levelRequired")
_ACHIEVED_LEVEL_FIELDS = ("achievedLevel", "achieved_level", "levelAchieved")
def _summarize_node(node: dict) -> dict:
"""Reduce a resolved JSON-LD node to the fields the prompt cares about.
Always returns a dict (never None) so the template can iterate
safely. Missing fields are empty strings rather than absent keys.
Output shape:
{
"iri": "<full IRI>",
"kind": "CredibilityFactor", # type/category
"label": "Use error", # human-readable name
"status": "not-assessed",
"required": "", # str — empty if missing
"achieved": "",
"description": "",
}
For credibility factors specifically (the most common affected node),
`label` is the regulatory-affairs-readable name (`factorType`).
"""
iri = ""
if isinstance(node, dict):
iri = str(node.get("@id") or node.get("id") or "")
return {
"iri": iri,
"kind": _str_first(node, _KIND_FIELDS),
"label": _str_first(node, _LABEL_FIELDS),
"status": _str_first(node, _STATUS_FIELDS),
"required": _str_first(node, _REQUIRED_LEVEL_FIELDS),
"achieved": _str_first(node, _ACHIEVED_LEVEL_FIELDS),
"description": str(node.get("description") or ""),
}
def _summarize_constituent(annotation: dict, package_doc: dict) -> dict:
"""Reduce one constituent firing of a COMPOUND to a prompt-ready dict.
`annotation` comes from `rules.parse_individual_annotations()`. We
additionally resolve its `affected_node` IRI in the package so the
prompt can say "W-AL-01 (Missing Uncertainty Quantification) on the
'Use error' factor" rather than just "W-AL-01 fired on _:b1".
"""
affected_iri = annotation.get("affected_node", "")
affected_node = _resolve_node_in_doc(package_doc, affected_iri) if affected_iri else None
return {
"patternId": annotation.get("patternId", ""),
"severity": annotation.get("severity", ""),
"description": annotation.get("description", ""),
"affected": _summarize_node(affected_node or {"@id": affected_iri, "id": affected_iri}),
}
def _str_first(node, keys: tuple[str, ...]) -> str:
"""Return first present-and-non-empty value for any of `keys` as str.
Treats `None` as missing but `0` / `False` / `""` as values to consider
(since `achievedLevel: 0` is meaningful and `""` empty-string means
"explicitly empty" — we want to keep walking for a better key).
"""
if not isinstance(node, dict):
return ""
for k in keys:
v = node.get(k)
if v is None:
continue
if isinstance(v, list):
joined = ", ".join(str(item) for item in v if item is not None)
if joined:
return joined
continue
s = str(v)
if s: # non-empty after stringification ('0' counts; '' doesn't)
return s
return ""
def extract_difference_contexts(
only_a: list[str],
only_b: list[str],
weakeners_a: list[dict],
weakeners_b: list[dict],
cou_identity_a: dict,
cou_identity_b: dict,
pack_name: str,
) -> list[DifferenceContext]:
"""Build DifferenceContexts from a DiffResult.
Uses the shape produced by `diff.run_structured()`. `only_a`/`only_b`
are sorted patternId lists; `weakeners_a`/`weakeners_b` carry the rich
dict (with severity, optional description).
"""
pack = extract_pack_context(pack_name)
cou_a = CouContext(
name=cou_identity_a.get("cou_name", ""),
device_class=cou_identity_a.get("device_class", ""),
model_risk_level=cou_identity_a.get("model_risk_level", ""),
)
cou_b = CouContext(
name=cou_identity_b.get("cou_name", ""),
device_class=cou_identity_b.get("device_class", ""),
model_risk_level=cou_identity_b.get("model_risk_level", ""),
)
by_pid_a = {w["patternId"]: w for w in weakeners_a if "patternId" in w}
by_pid_b = {w["patternId"]: w for w in weakeners_b if "patternId" in w}
out: list[DifferenceContext] = []
for pid in only_a:
w = by_pid_a.get(pid, {})
out.append(DifferenceContext(
pattern_id=pid,
severity=str(w.get("severity", "Medium")),
only_in="A",
cou_with=cou_a,
cou_without=cou_b,
description=str(w.get("description", "")),
pack=pack,
))
for pid in only_b:
w = by_pid_b.get(pid, {})
out.append(DifferenceContext(
pattern_id=pid,
severity=str(w.get("severity", "Medium")),
only_in="B",
cou_with=cou_b,
cou_without=cou_a,
description=str(w.get("description", "")),
pack=pack,
))
return out
def extract_violation_contexts(
violations: list[dict],
pack_name: str,
) -> list[ViolationContext]:
"""Build ViolationContexts from a ShaclResult.violations list.
`violations` shape per `shacl_friendly.run_shacl_multi`: each dict
carries at minimum `path`, `message`, `severity`, `focus_node`, and
optionally `expected` / `actual` / `fix_suggestion` (key names depend
on the extractor; we look up several aliases).
"""
pack = extract_pack_context(pack_name)
return [
ViolationContext(
constraint_path=str(_first(v, ("path", "constraint", "result_path"), "")),
severity=str(_first(v, ("severity",), "Medium")),
affected_node=str(_first(v, ("focus_node", "node", "affected_node"), "")),
expected=str(_first(v, ("expected", "expected_value"), "")),
actual=str(_first(v, ("actual", "actual_value", "value"), "")),
description=str(_first(v, ("fix_suggestion", "message", "description"), "")),
pack=pack,
)
for v in violations
]
def _first(d: dict, keys: tuple[str, ...], default: Any) -> Any:
"""Return d[k] for the first k in keys that's present and truthy."""
for k in keys:
v = d.get(k)
if v not in (None, ""):
return v
return default