Skip to content

feat: normailze intinsics interfaces#1028

Open
akihikokuroda wants to merge 4 commits intogenerative-computing:mainfrom
akihikokuroda:issue1003
Open

feat: normailze intinsics interfaces#1028
akihikokuroda wants to merge 4 commits intogenerative-computing:mainfrom
akihikokuroda:issue1003

Conversation

@akihikokuroda
Copy link
Copy Markdown
Member

@akihikokuroda akihikokuroda commented May 6, 2026

Misc PR

Type of PR

  • Bug Fix
  • New Feature
  • Documentation
  • Other

Description

Testing

  • Tests added to the respective file if code was changed
  • New code has 100% coverage if code as added
  • Ensure existing tests and github automation passes (a maintainer will kick off the github automation when the rest of the PR is populated)

Attribution

  • AI coding assistants used: claude

Signed-off-by: Akihiko Kuroda <akihikokuroda2020@gmail.com>
@akihikokuroda akihikokuroda requested a review from a team as a code owner May 6, 2026 17:18
@github-actions github-actions Bot added the enhancement New feature or request label May 6, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 6, 2026

The PR description has been updated. Please fill out the template for your PR to be reviewed.

Copy link
Copy Markdown
Contributor

@planetf1 planetf1 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

threading looks good, and catching the float → str return type mismatch was worth doing. Two things I'd want resolved before merging: the from_context tests won't actually exercise the code path they're meant to cover, and the documents positional change breaks existing callers without any mention in the issue or PR. The latter is easy to fix without the break — see inline.

# Add documents to the last assistant message
last_turn = context.last_turn()
assert last_turn is not None and last_turn.output is not None
assert last_turn.output.value is not None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These assertions will never pass as written. _read_guardian_input builds context via context.add(Message(...)), which means the last node is a Message, not a ModelOutputThunk — so last_turn() returns ContextTurn(last_element, None) (base.py:919). .output is always None here. The test aborts before reaching the code it's supposed to cover, so the documents=None branch in factuality_detection is entirely uncovered.

The fix is to use the same extraction path that guardian.py itself falls back to:

last_turn = context.last_turn()
assert last_turn is not None
if last_turn.output is not None and last_turn.output.value is not None:
    content = last_turn.output.value
else:
    assert isinstance(last_turn.model_input, Message)
    content = last_turn.model_input.content
rewound = context.previous_node
assert rewound is not None
context_with_docs: ChatContext = rewound.add(  # type: ignore[assignment]
    Message("assistant", content, documents=documents)
)

Same issue at lines 220–221 in test_factuality_correction_from_context.

def factuality_detection(context: ChatContext, backend: AdapterMixin) -> float:
"""Determine is the last response is factually incorrect.
def factuality_detection(
documents: collections.abc.Iterable[str | Document] | None,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Making documents the new first positional arg is a silent break — existing callers doing factuality_detection(ctx, backend) now pass ctx as documents and get a confusing error rather than a helpful one. Neither #1003 nor this PR mentions it as a breaking change, and our policy is to avoid that without explicit callout.

The good news is the issue just asks for parity with the other intrinsics — we can get there without the break by making documents keyword-only:

def factuality_detection(
    context: ChatContext,
    backend: AdapterMixin,
    *,
    documents: collections.abc.Iterable[str | Document] | None = None,
    model_options: dict | None = None,
) -> str:

Existing call sites continue to work, new callers pass documents= explicitly, and it's consistent with how model_options is handled everywhere else in this PR. Worth also updating the AGENTS.md Section 13 table with the new signature.


# If documents are provided, add them to the last assistant message
if documents is not None:
# Get the last turn and add documents to it
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block extracting the assistant content, rewinding the context, and reattaching documents is identical in factuality_correction (~lines 303–336). If either of the fixes above lands here, it'll need to land in both places. Would be cleaner to pull it into a private helper — something like _reattach_documents(context, documents) -> ChatContext — which also gives us one place to sort out the cast() vs type: ignore question below.


# Extract assistant content from either output (if generated) or model_input (if manually added)
if last_turn.output is not None and last_turn.output.value is not None:
assistant_content = last_turn.output.value
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small edge case: if last_turn.output is not None but .value is None (uncomputed thunk, failed generation), the code falls through to model_input and ends up wrapping the user's question as the assistant's response. That'll produce a factuality score on the wrong text with no indication anything went wrong. Raising explicitly there rather than falling through would be much easier to debug.

raise ValueError("Cannot rewind context past the root node")
# Type ignore because rewound is Context but we know it's ChatContext
context = rewound.add( # type: ignore[assignment]
Message(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

# type: ignore[assignment] is hiding a real mismatch: previous_node.add(...) returns Context, not ChatContext. Worth using cast(ChatContext, rewound.add(...)) with a short note explaining why, rather than silencing mypy here — if the type hierarchy shifts this will fail at runtime instead of at type-check time. As above, extracting the helper means one fix instead of two.

Signed-off-by: Akihiko Kuroda <akihikokuroda2020@gmail.com>
Signed-off-by: Akihiko Kuroda <akihikokuroda2020@gmail.com>
@akihikokuroda akihikokuroda requested a review from planetf1 May 7, 2026 14:13
@psschwei psschwei requested a review from jakelorocco May 8, 2026 15:08
Comment on lines +195 to +249
def _reattach_documents(
context: ChatContext, documents: collections.abc.Iterable[str | Document]
) -> ChatContext:
"""Rewind context and re-add the last message with documents attached.

Extracts the assistant's response from the last turn (handling both generated
output and manually-added messages), then rewinds the context to before that
message and re-adds it with the provided documents.

Args:
context: Context containing the conversation.
documents: Documents to attach to the last assistant message.

Returns:
New context with documents attached to the last assistant message.

Raises:
ValueError: If context cannot be rewound or assistant content cannot be extracted.
"""
last_turn = context.last_turn()
if last_turn is None:
raise ValueError("Cannot reattach documents: context has no last turn")

# Extract assistant content, preferring generated output over input
if last_turn.output is not None and last_turn.output.value is not None:
assistant_content = last_turn.output.value
elif last_turn.output is not None and last_turn.output.value is None:
# Uncomputed thunk — avoid silent fallthrough to model_input
raise ValueError(
"Cannot reattach documents: last turn output is uncomputed (thunk with no value)"
)
elif last_turn.model_input is not None and isinstance(
last_turn.model_input, Message
):
assistant_content = last_turn.model_input.content
else:
raise ValueError(
"Cannot reattach documents: cannot extract assistant content from last turn"
)

# Rewind and re-add with documents
rewound = context.previous_node
if rewound is None:
raise ValueError("Cannot rewind context past the root node")

return cast(
ChatContext,
rewound.add(
Message(
"assistant",
assistant_content,
documents=_coerce_to_documents(documents),
)
),
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We actually have two helper functions already (_resolve_question and _resolve_response) utilized by the other intrinsics. Could we utilize that function? I believe it serves the same purpose.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still working on this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now it's fixed.

Comment on lines +252 to +257
def factuality_detection(
context: ChatContext,
backend: AdapterMixin,
*,
documents: collections.abc.Iterable[str | Document] | None = None,
model_options: dict | None = None,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think to match the function signature of the other intrinsics, this should actually accept a response parameter that's of type str | None. We only extract / modify the last message if response isn't provided.

Comment on lines +296 to +302
def factuality_correction(
context: ChatContext,
backend: AdapterMixin,
*,
documents: collections.abc.Iterable[str | Document] | None = None,
model_options: dict | None = None,
) -> str:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the function signature comment above.

Comment thread test/backends/test_openai_intrinsics.py Outdated
context = context.add(Message(m["role"], m["content"]))

result = guardian.factuality_detection(context, call_intrinsic_backend)
result = guardian.factuality_detection(docs, context, call_intrinsic_backend)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please make sure the tests run. I might be misreading things but shouldn't this throw an error given the function signature define above?

Comment on lines +223 to +239
# Extract assistant content using the same logic as _reattach_documents
last_turn = context.last_turn()
assert last_turn is not None
if last_turn.output is not None and last_turn.output.value is not None:
content = last_turn.output.value
else:
assert isinstance(last_turn.model_input, Message)
content = last_turn.model_input.content

rewound = context.previous_node
assert rewound is not None
context_with_docs: ChatContext = rewound.add( # type: ignore[assignment]
Message("assistant", content, documents=documents)
)

result = guardian.factuality_correction(context, backend)
# Test with documents=None (should extract from context)
result = guardian.factuality_correction(context_with_docs, backend)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this replicating the logic from the helper function? Should we be able to use that here? I might be missing what this is testing.

Comment on lines 151 to +156
def guardian_check(
context: ChatContext,
backend: AdapterMixin,
criteria: str,
target_role: str = "assistant",
model_options: dict | None = None,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should add the same document handling to this one as well. One of the modes for this is groundedness which allows for providing context documents: https://huggingface.co/ibm-granite/granitelib-guardian-r1.0/blob/main/guardian-core/README.md#intended-use

You can also take a look at Example 3 in docs/examples/intrinsics/guardian_core.py. I think it would be worth adding additional commentary in the function signature describing that documents should primarily be used for this purpose / that specific criteria.

@akihikokuroda akihikokuroda requested a review from jakelorocco May 8, 2026 22:51
Signed-off-by: Akihiko Kuroda <akihikokuroda2020@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: intrinsic function signatures

3 participants