Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 152 additions & 12 deletions mellea/backends/adapters/io_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,157 @@ def parse(self, raw: str) -> dict[str, object]:
return data


_HALLUCINATION_REQUIRED_KEYS: frozenset[str] = frozenset(
{"response_begin", "response_end", "response_text", "faithfulness", "explanation"}
)
_HALLUCINATION_FAITHFULNESS_LABELS: tuple[str, ...] = (
"faithful",
"partial",
"unfaithful",
"NA",
)


class _HallucinationDetectionContract(IOContract):
"""Validate and deduplicate `hallucination_detection` adapter output.

Unlike other contracts in this module (which return parsed data unchanged),
`parse()` drops duplicate records that share `(response_begin, response_end)`
and agree on `faithfulness` (keeping the first record). Duplicate spans that
disagree on `faithfulness`, or distinct spans whose half-open intervals
`[response_begin, response_end)` overlap, raise `AdapterSchemaMismatchError`.

The `faithfulness` value set (`"faithful"`, `"partial"`, `"unfaithful"`,
`"NA"`) is closed on purpose (fail-closed): on Granite Switch, `io.yaml`
comes from the served model's `io_configs/` while this contract comes from
Mellea's registry, so a Switch release adding a new label raises
`AdapterSchemaMismatchError` until Mellea is upgraded.
"""

def build_prompt(self, **_kwargs: object) -> Component:
raise NotImplementedError(_BUILD_PROMPT_NOT_IMPLEMENTED)

def parse(self, raw: str) -> dict[str, object]:
"""Parse, validate, and deduplicate `hallucination_detection` output.

Args:
raw (str): Raw JSON string from the model.

Returns:
dict[str, object]: `{"items": <list of validated, deduplicated dicts>}`.

Raises:
ValueError: When `raw` is not valid JSON, is not a JSON array, or
contains a non-object element.
AdapterSchemaMismatchError: When any record is missing a required key,
has an invalid `faithfulness` value, has non-`int` (or `bool`) or
inverted span offsets (`response_begin > response_end`), has a
non-`str` `explanation`, conflicts on `faithfulness` for a repeated
`(response_begin, response_end)` span, or overlaps another span's
half-open interval `[response_begin, response_end)`.
"""
data = json.loads(raw)
if not isinstance(data, list):
Comment thread
CG-5228 marked this conversation as resolved.
Resolved
raise ValueError(
f"Adapter 'hallucination_detection' output must be a JSON array, "
f"got {type(data).__name__}."
)

seen_spans: dict[tuple[int, int], str] = {}
accepted_spans: list[tuple[int, int, frozenset[str]]] = []
deduplicated: list[dict[str, object]] = []

for item in data:
if not isinstance(item, dict):
raise ValueError(
f"Adapter 'hallucination_detection' output array must contain "
f"only JSON objects, got a {type(item).__name__} element."
)
observed = frozenset(item.keys())
missing = _HALLUCINATION_REQUIRED_KEYS - observed
if missing:
raise AdapterSchemaMismatchError(
"hallucination_detection", observed, _HALLUCINATION_REQUIRED_KEYS
)

faithfulness = item["faithfulness"]
if (
not isinstance(faithfulness, str)
or faithfulness not in _HALLUCINATION_FAITHFULNESS_LABELS
):
valid_labels = ", ".join(
repr(lbl) for lbl in _HALLUCINATION_FAITHFULNESS_LABELS
)
raise AdapterSchemaMismatchError(
"hallucination_detection",
observed,
_HALLUCINATION_REQUIRED_KEYS,
f"invalid `faithfulness` value {faithfulness!r}; expected one of "
Comment thread
CG-5228 marked this conversation as resolved.
Resolved
f"{valid_labels} (upgrade `mellea` if the upstream adapter added "
f"a new label)",
)

begin = item["response_begin"]
end = item["response_end"]
if (
isinstance(begin, bool)
or not isinstance(begin, int)
or isinstance(end, bool)
or not isinstance(end, int)
):
raise AdapterSchemaMismatchError(
"hallucination_detection",
observed,
_HALLUCINATION_REQUIRED_KEYS,
"`response_begin` and `response_end` must be `int` (not `bool`)",
)
if begin > end:
raise AdapterSchemaMismatchError(
"hallucination_detection",
observed,
_HALLUCINATION_REQUIRED_KEYS,
f"`response_begin` ({begin}) must be <= `response_end` ({end})",
)

explanation = item["explanation"]
if not isinstance(explanation, str):
raise AdapterSchemaMismatchError(
"hallucination_detection",
observed,
_HALLUCINATION_REQUIRED_KEYS,
f"`explanation` must be a `str`, got {type(explanation).__name__}",
)

span = (begin, end)
if span in seen_spans:
if seen_spans[span] != faithfulness:
raise AdapterSchemaMismatchError(
"hallucination_detection",
observed,
_HALLUCINATION_REQUIRED_KEYS,
f"conflicting `faithfulness` values ({seen_spans[span]!r} vs "
f"{faithfulness!r}) for duplicate span {span}",
)
continue

seen_spans[span] = faithfulness
accepted_spans.append((begin, end, observed))
deduplicated.append(item)

for i, (a_begin, a_end, _) in enumerate(accepted_spans):
for b_begin, b_end, b_observed in accepted_spans[i + 1 :]:
if max(a_begin, b_begin) < min(a_end, b_end):
raise AdapterSchemaMismatchError(
"hallucination_detection",
b_observed,
_HALLUCINATION_REQUIRED_KEYS,
f"overlapping response spans [{a_begin}, {a_end}) and "
f"[{b_begin}, {b_end})",
)

return {"items": deduplicated}


_INTRINSIC_IO_CONTRACTS: dict[str, IOContract] = {
"answerability": _DictContract("answerability", frozenset({"answerability"})),
"query_rewrite": _DictContract("query_rewrite", frozenset({"rewritten_question"})),
Expand All @@ -206,18 +357,7 @@ def parse(self, raw: str) -> dict[str, object]:
}
),
),
"hallucination_detection": _ListContract(
"hallucination_detection",
frozenset(
{
"response_begin",
"response_end",
"response_text",
"faithfulness",
"explanation",
}
),
),
"hallucination_detection": _HallucinationDetectionContract(),
"policy-guardrails": _PolicyGuardrailsContract(),
"guardian-core": _GuardianCheckContract(),
"factuality-detection": _DictContract("factuality-detection", frozenset({"score"})),
Expand Down
8 changes: 7 additions & 1 deletion mellea/formatters/granite/intrinsics/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -715,11 +715,17 @@ def _transform(self, value: Any, path: tuple, prepare_output: dict) -> dict:
document_ids = prepare_output.get("document_ids")
message_indices = prepare_output.get("message_indices")

if not isinstance(value, int):
if isinstance(value, bool) or not isinstance(value, int):
raise TypeError(
f"Expected integer sentence number at path {path}, but "
f"found non-integer value {value} of type {type(value)}"
)
num_sentences = len(begins)
if value < 0 or value >= num_sentences:
raise ValueError(
f"Sentence number {value} at path {path} is out of range for "
f"{num_sentences} sentence(s)"
)
sentence_num = value

result = {}
Expand Down
6 changes: 5 additions & 1 deletion mellea/stdlib/components/intrinsic/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,11 @@ def find_context_attributions(

Raises:
ValueError: When the model output is not valid JSON, is not a
JSON array, or contains a non-object element.
JSON array, contains a non-object element, or contains a
sentence index that is negative or out of range for the decoded
source sentences.
TypeError: When sentence decoding encounters a non-integer (or
`bool`) sentence index in the model output.
AdapterSchemaMismatchError: When any record in the output is missing a
required field.
AloraActivationError: When the resolved adapter is an aLoRA whose
Expand Down
45 changes: 36 additions & 9 deletions mellea/stdlib/components/intrinsic/rag.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,11 @@ def find_citations(

Raises:
ValueError: When the model output is not valid JSON, is not a
JSON array, or contains a non-object element.
JSON array, contains a non-object element, or contains a
sentence index that is negative or out of range for the decoded
source sentences.
TypeError: When sentence decoding encounters a non-integer (or
`bool`) sentence index in the model output.
AdapterSchemaMismatchError: When any record in the output is missing a
required field.
AloraActivationError: When the resolved adapter is an aLoRA whose
Expand Down Expand Up @@ -254,15 +258,30 @@ def flag_hallucinated_content(
user question are faithful to the retrieved document snippets. Sentences that do
not align with the retrieved snippets are flagged as potential hallucinations.

The `faithfulness` field in each record is a string label (e.g.
`"faithful"`, `"hallucinated"`); coercion to a boolean is the caller's
responsibility.
The `faithfulness` field in each record is one of the four string labels
defined by the adapter's `io.yaml` schema:
- `"faithful"` — the sentence is fully supported by the retrieved documents.
- `"partial"` — the sentence is only partially supported by the retrieved
documents.
- `"unfaithful"` — the sentence is not supported by (or contradicts) the
retrieved documents.
- `"NA"` — faithfulness does not apply (for example, the sentence makes no
factual claim that needs grounding in the documents).

Coercion to a boolean is the caller's responsibility.

Output contract — each record must contain: `response_begin`,
`response_end`, `response_text`, `faithfulness`, `explanation`. A
record missing any of these keys raises
:class:`~mellea.backends.adapters.AdapterSchemaMismatchError`; extra optional
keys do not raise (forward-compatible).
record missing any of these keys raises `AdapterSchemaMismatchError`;
extra optional keys do not raise (forward-compatible). In addition, the
contract validates that `faithfulness` is one of `"faithful"`, `"partial"`,
`"unfaithful"`, or `"NA"`; `response_begin` and `response_end` are `int`
(not `bool`) with `response_begin <= response_end`; and `explanation` is a
`str`. Duplicate records for the same `(response_begin, response_end)`
span that agree on `faithfulness` are deduplicated (keeping the first
record), whereas duplicate spans with conflicting `faithfulness` values or
distinct spans whose half-open intervals `[response_begin, response_end)`
overlap raise `AdapterSchemaMismatchError`.

Args:
response: The assistant's response to the user's question in the last turn
Expand All @@ -284,9 +303,17 @@ def flag_hallucinated_content(

Raises:
ValueError: When the model output is not valid JSON, is not a
JSON array, or contains a non-object element.
JSON array, contains a non-object element, or contains a
sentence index that is negative or out of range for the decoded
response sentences.
TypeError: When sentence decoding encounters a non-integer (or
`bool`) sentence index in the model output.
AdapterSchemaMismatchError: When any record in the output is missing a
required field.
required field, has an invalid `faithfulness` label, has non-`int`
(or `bool`) or inverted span offsets (`response_begin > response_end`),
has a non-`str` `explanation`, has conflicting `faithfulness`
labels for the same `(response_begin, response_end)` span, or has
overlapping `[response_begin, response_end)` spans.
AloraActivationError: When the resolved adapter is an aLoRA whose
declared activation sequence is absent from the assembled prompt.
"""
Expand Down
Loading
Loading