cloudronin's picture
push build context (uofa source + packs + space app)
a28ec65 verified
Raw
History Blame Contribute Delete
5.03 kB
"""Severity contextualization function (spec v0.4 §2.3, P-G).
ONE LLM call per command. Model sees every firing's context and
produces a relative ranking 1..N where rank 1 is most consequential
for this package's specific COU. Distinct from the rule's static
severity field — the contextual rank weights by COU stakes, evidence
centrality, and compound interactions.
Output goes into the envelope's `contextual_severity` slot, keyed by
patternId, with `{rank, rationale}` per entry.
"""
from __future__ import annotations
import json
import logging
from uofa_cli.interpretation.cache import ExplanationCache, compute_key
from uofa_cli.interpretation.context import FiringContext, ViolationContext
from uofa_cli.interpretation.dispatcher import applies_to_commands
from uofa_cli.interpretation.envelope import INTERPRETATION_VERSION
from uofa_cli.interpretation.functions.group import (
_first_cou,
_first_pack,
_generate_and_parse,
_noop_cm,
_render_firings_block,
_top_n,
)
from uofa_cli.interpretation.templates import has_template, render
from uofa_cli.llm.backend import GenerationOptions
from uofa_cli.llm.errors import LLMError
log = logging.getLogger(__name__)
_CONTEXTUALIZE_SCHEMA = {
"type": "object",
"properties": {
"contextual_severity": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"rank": {"type": "integer"},
"rationale": {"type": "string"},
},
"required": ["rank"],
},
},
},
"required": ["contextual_severity"],
}
@applies_to_commands("rules", "check", "shacl")
def contextualize_severity(
*,
command: str,
contexts: list,
structured_output,
backend,
options,
cache: ExplanationCache | None = None,
) -> dict:
"""Rank firings 1..N by contextual importance.
Returns ``{"contextual_severity": {patternId: {rank, rationale}}}``.
The single LLM call sees every firing + its affected evidence + the
COU framing and produces the ranking in one pass — separate calls
per item would give the model no comparative context to rank from.
"""
pack_name = options.pack_name
if not has_template("rules", "contextualize", pack_name):
log.warning(
"No `rules/contextualize.jinja2` template for pack %r; skipping",
pack_name,
)
return {}
if command == "shacl":
items = [c for c in contexts if isinstance(c, ViolationContext)]
if not has_template("shacl", "contextualize", pack_name):
return {}
else:
items = [c for c in contexts if isinstance(c, FiringContext)]
if not items:
return {}
if options.max_items is not None and options.max_items > 0:
items = _top_n(items, options.max_items)
template_command = "shacl" if command == "shacl" else "rules"
firings_text = _render_firings_block(items)
template_vars = {
"firings_text": firings_text,
"cou": _first_cou(items),
"pack": _first_pack(items),
}
prompt = render(template_command, "contextualize", pack_name, **template_vars)
cache_key = None
if cache is not None:
cache_key = compute_key(
prompt=prompt,
backend=backend.name(),
model=backend.model(),
interp_version=INTERPRETATION_VERSION,
)
cached = cache.get(cache_key)
if cached is not None:
return cached
gen_options = GenerationOptions(
temperature=0.0,
max_tokens=4096,
extra={"think": False},
)
spinner_factory = getattr(options, "spinner_factory", None) or _noop_cm
try:
with spinner_factory("Ranking contextual severity..."):
if backend.supports_structured_output():
try:
result = backend.generate_structured(prompt, _CONTEXTUALIZE_SCHEMA, gen_options)
except NotImplementedError:
result = _generate_and_parse(backend, prompt, gen_options)
else:
result = _generate_and_parse(backend, prompt, gen_options)
except (LLMError, json.JSONDecodeError, ValueError) as exc:
log.warning("contextualize failed: %s", getattr(exc, "diagnostic", exc))
return {}
raw = result.get("contextual_severity", {}) if isinstance(result, dict) else {}
out_dict: dict = {}
if isinstance(raw, dict):
for pid, info in raw.items():
if not isinstance(info, dict):
continue
out_dict[str(pid)] = {
"rank": int(info.get("rank", 0)) if str(info.get("rank", "")).lstrip("-").isdigit() else 0,
"rationale": str(info.get("rationale", "")).strip(),
}
out = {"contextual_severity": out_dict}
if cache is not None and cache_key is not None:
cache.put(cache_key, out)
return out