import json
from dataclasses import dataclass, field
from mlflow.exceptions import MlflowException
ISSUE_EVIDENCE_METADATA_KEY = "mlflow.issue.evidence"
ISSUE_EVIDENCE_SCHEMA_VERSION = 1
# The assessment metadata column is `TEXT`, which MySQL caps at 65,535 bytes. The store writes the
# whole metadata map as one JSON document, so that budget covers every key, every value and the
# escaping the outer encoding adds -- not the evidence payload alone.
MAX_ASSESSMENT_METADATA_BYTES = 65_535
HIGHLIGHT_TONES = ("error", "warning", "success")
def _require_string(value, field_name: str) -> str:
text = value.strip() if isinstance(value, str) else ""
if not text:
raise MlflowException.invalid_parameter_value(f"{field_name} must be a non-empty string")
return text
[docs]@dataclass
class IssueEvidenceStep:
"""
One step of the walkthrough explaining an issue on a trace.
Args:
span_id: The span this step cites. Must belong to the step's trace.
text: The step's prose, shown in the Issue's evidence card.
highlights: Exact substrings of ``text`` to emphasize. At least one is required, so a
step always points at the part of its text that carries the finding.
highlight_tone: ``error``, ``warning`` or ``success``.
mono: Whether ``text`` is preformatted, such as a payload or a stack trace.
"""
span_id: str
text: str
highlights: list[str]
highlight_tone: str = "warning"
mono: bool = False
def __post_init__(self):
self.span_id = _require_string(self.span_id, "span_id")
self.text = _require_string(self.text, "text")
if not isinstance(self.highlights, list) or not self.highlights:
raise MlflowException.invalid_parameter_value(
"highlights must contain at least one highlight"
)
self.highlights = [
_require_string(highlight, f"highlights[{index}]")
for index, highlight in enumerate(self.highlights)
]
# Exact substrings keep the rendered emphasis honest: the UI highlights by searching the
# text, so a paraphrased highlight would silently emphasize nothing.
if missing := [highlight for highlight in self.highlights if highlight not in self.text]:
raise MlflowException.invalid_parameter_value(
f"highlights must be exact substrings of text, but {missing[0]!r} is not"
)
if self.highlight_tone not in HIGHLIGHT_TONES:
raise MlflowException.invalid_parameter_value(
f"highlight_tone must be one of {', '.join(HIGHLIGHT_TONES)}, "
f"got {self.highlight_tone!r}"
)
if not isinstance(self.mono, bool):
raise MlflowException.invalid_parameter_value("mono must be a boolean")
[docs] def to_dict(self) -> dict[str, object]:
step: dict[str, object] = {
"span_id": self.span_id,
"text": self.text,
"highlights": list(self.highlights),
"highlight_tone": self.highlight_tone,
}
if self.mono:
step["mono"] = True
return step
[docs] @classmethod
def from_dict(cls, value, field_name: str = "step") -> "IssueEvidenceStep":
if not isinstance(value, dict):
raise MlflowException.invalid_parameter_value(f"{field_name} must be an object")
mono = value.get("mono", False)
return cls(
span_id=value.get("span_id"),
text=value.get("text"),
highlights=value.get("highlights"),
highlight_tone=value.get("highlight_tone", "warning"),
mono=mono,
)
[docs]@dataclass
class IssueEvidence:
"""
The evidence for one issue on one trace: a short summary and an ordered walkthrough.
The summary is stored as the issue reference's rationale and the steps as versioned metadata,
so a reader that predates structured evidence still shows the summary.
Args:
summary: One or two sentences naming what went wrong on this trace.
steps: The ordered walkthrough. At least one step is required.
primary_span_id: The span that owns the finding, when one does. Must be cited by a step.
"""
summary: str
steps: list[IssueEvidenceStep] = field(default_factory=list)
primary_span_id: str | None = None
def __post_init__(self):
self.summary = _require_string(self.summary, "summary")
if not isinstance(self.steps, list) or not self.steps:
raise MlflowException.invalid_parameter_value("evidence must contain at least one step")
self.steps = [
step
if isinstance(step, IssueEvidenceStep)
else IssueEvidenceStep.from_dict(step, f"steps[{index}]")
for index, step in enumerate(self.steps)
]
if self.primary_span_id is not None:
self.primary_span_id = _require_string(self.primary_span_id, "primary_span_id")
# A primary span nothing walks through would send the trace view to a span the card
# never explains.
if all(step.span_id != self.primary_span_id for step in self.steps):
raise MlflowException.invalid_parameter_value(
"primary_span_id must be cited by an evidence step"
)
@property
def span_ids(self) -> list[str]:
"""Every span the walkthrough cites, in step order and without repetition."""
return list(dict.fromkeys(step.span_id for step in self.steps))
[docs] def validate_spans(self, known_span_ids) -> None:
"""
Check that every cited span belongs to the trace.
Args:
known_span_ids: The span IDs the trace actually carries.
Raises:
mlflow.exceptions.MlflowException: If a step cites a span the trace does not contain.
A fabricated span locator would render an unopenable step, so it is rejected at
write time.
"""
known = set(known_span_ids)
if unknown := [span_id for span_id in self.span_ids if span_id not in known]:
raise MlflowException.invalid_parameter_value(
f"Evidence cites span {unknown[0]!r}, which is not part of the trace"
)
[docs] @classmethod
def from_assessment(cls, assessment) -> "IssueEvidence | None":
"""
Read the structured evidence an issue reference assessment carries.
Returns ``None`` both for a reference that carries no evidence and for one whose stored
evidence no longer parses. Reads stay lenient on purpose: a reader's job is to show what
the reference does say, and its rationale remains readable either way. Writes are where
malformed evidence is rejected.
"""
metadata = getattr(assessment, "metadata", None) or {}
summary = getattr(assessment, "rationale", None) or ""
if not summary:
return None
try:
evidence = cls.from_metadata_value(summary, metadata.get(ISSUE_EVIDENCE_METADATA_KEY))
except MlflowException:
return None
if evidence is None:
return None
# The assessment's own span is the primary one, but only when the walkthrough explains it;
# otherwise the card would offer to open a span it never describes.
span_id = getattr(assessment, "span_id", None) or None
if span_id and any(step.span_id == span_id for step in evidence.steps):
evidence.primary_span_id = span_id
return evidence