From 88f962b3190e27c08a193f449e6c287ca816ed2c Mon Sep 17 00:00:00 2001 From: david Date: Thu, 1 Oct 2026 14:35:58 +0200 Subject: [PATCH] feat(models): type exchange info as Ask/PostExchangeInfo The KE OpenAPI spec reports exchanges with ANSWER KIs as AskExchangeInfo (with the answered bindings) and with REACT KIs as PostExchangeInfo (with argument and result bindings), but we parsed both into a flat ExchangeInfo and dropped the bindings. Since KE 1.5.0 entries carry a knowledgeInteractionType discriminator and one ASK/POST result may mix both kinds. Older KEs omit it, so the subtype is inferred from the binding set fields, falling back to the result kind. Exchange info bindings stay raw: they follow the other KI's graph pattern. Closes #45 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CONTEXT.md | 12 +- README.md | 11 +- examples/03-ask_interaction.py | 10 +- src/knowledge_mapper/__init__.py | 3 + src/knowledge_mapper/ke/models.py | 79 +++++++++- src/knowledge_mapper/knowledge_interaction.py | 11 +- src/knowledge_mapper/testing/fake_client.py | 10 +- tests/test_ask_and_post.py | 20 ++- tests/test_client.py | 149 ++++++++++++++++++ 9 files changed, 291 insertions(+), 14 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index fc56088..baee126 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -176,7 +176,7 @@ result = await kb.ask(binding_set, ki_name="...") # Returns result BindingSet or list[BindingModel] result = await kb.post(binding_set, ki_name="...") -# Return ExchangeResult(binding_set=..., exchange_info=[ExchangeInfo, ...]) +# Return ExchangeResult(binding_set=..., exchange_info=[AskExchangeInfo | PostExchangeInfo, ...]) result = await kb.ask_with_info(binding_set, ki_name="...") result = await kb.post_with_info(binding_set, ki_name="...") @@ -199,6 +199,16 @@ info (issue #49). `ExchangeResult` is a frozen dataclass defined in `knowledge_ (wire) models. Blocking bridges for sync handlers: `ask_sync()`, `post_sync()`, `ask_with_info_sync()`, `post_with_info_sync()`. +Exchange info entries are `AskExchangeInfo` (exchange with an ANSWER KI, raw `binding_set`) +or `PostExchangeInfo` (exchange with a REACT KI, raw `argument_binding_set` / +`result_binding_set`), both subclasses of `ExchangeInfo` (issue #45). A single ASK or POST +result may mix both, since the reasoner can combine KIs. The KE tags entries with +`knowledgeInteractionType` (`AnswerKnowledgeInteraction` / `ReactKnowledgeInteraction`) only +after 1.5.0; for older KEs the subtype is inferred from the binding set fields present, +falling back to the result kind (ASK → `AskExchangeInfo`, POST → `PostExchangeInfo`). The +bindings stay raw because they follow the other KI's graph pattern, not the local binding +model. + #### Handling loop ```python diff --git a/README.md b/README.md index 77ccc44..2217d96 100644 --- a/README.md +++ b/README.md @@ -154,14 +154,23 @@ people = await kb.ask(binding_set, ki_name="...", binding_model=MyModel) results = await kb.post(binding_set, ki_name="...", result_binding_model=ResultModel) ``` -To also get the exchange info reported by the Knowledge Engine (which knowledge bases were involved, with status and timing), use the `*_with_info` variants. They return an `ExchangeResult` with `binding_set` and `exchange_info` (a list of `ExchangeInfo`): +To also get the exchange info reported by the Knowledge Engine (which knowledge interactions were involved, with status, timing and the bindings they exchanged), use the `*_with_info` variants. They return an `ExchangeResult` with `binding_set` and `exchange_info`, a list of `AskExchangeInfo` (exchanges with ANSWER KIs) and `PostExchangeInfo` (exchanges with REACT KIs). Both kinds can occur for either ASK or POST, because the reasoner may combine KIs: ```python +from knowledge_mapper import AskExchangeInfo, PostExchangeInfo + result = await kb.ask_with_info(binding_set, ki_name="...", binding_model=MyModel) for info in result.exchange_info: print(info.knowledge_base_id, info.status, info.failed_message) + match info: + case AskExchangeInfo(): + print("answered:", info.binding_set) + case PostExchangeInfo(): + print("reacted:", info.argument_binding_set, info.result_binding_set) ``` +The bindings in the exchange info are raw (`dict[str, str]`), because they follow the other KI's graph pattern rather than your binding model. + Sync handlers can use `ask_sync()` / `post_sync()` and `ask_with_info_sync()` / `post_with_info_sync()`. ### `BindingModel` diff --git a/examples/03-ask_interaction.py b/examples/03-ask_interaction.py index 61f23ff..ffb905b 100644 --- a/examples/03-ask_interaction.py +++ b/examples/03-ask_interaction.py @@ -7,7 +7,13 @@ from rdflib import URIRef from shared import get_example_logger -from knowledge_mapper import BindingModel, KnowledgeBase, Literal, Uri +from knowledge_mapper import ( + AskExchangeInfo, + BindingModel, + KnowledgeBase, + Literal, + Uri, +) EXAMPLE_NAME = "ask-interaction" logger = get_example_logger(EXAMPLE_NAME) @@ -66,6 +72,8 @@ async def main(): f"Exchanged with {info.knowledge_base_id}: {info.status} " f"({info.exchange_end - info.exchange_start})" ) + if isinstance(info, AskExchangeInfo): + logger.info(f" answered with: {info.binding_set}") await kb.unregister() logger.info("KB unregistered.") diff --git a/src/knowledge_mapper/__init__.py b/src/knowledge_mapper/__init__.py index 49af9a2..01dec20 100644 --- a/src/knowledge_mapper/__init__.py +++ b/src/knowledge_mapper/__init__.py @@ -4,6 +4,8 @@ from .kb.builder import KnowledgeBaseBuilder from .kb.knowledge_base import KnowledgeBase from .ke.models import ( + AnyExchangeInfo, + AskExchangeInfo, BindingModel, BindingSet, Datatype, @@ -12,6 +14,7 @@ KnowledgeInteraction, KnowledgeInteractionId, Literal, + PostExchangeInfo, RdfLiteral, Uri, validate_kb_id, diff --git a/src/knowledge_mapper/ke/models.py b/src/knowledge_mapper/ke/models.py index 9e277dd..4c04ec0 100644 --- a/src/knowledge_mapper/ke/models.py +++ b/src/knowledge_mapper/ke/models.py @@ -1,18 +1,21 @@ -from collections.abc import Sequence +from collections.abc import Callable, Sequence from dataclasses import dataclass from datetime import datetime from enum import StrEnum from typing import Annotated, Any, NewType, Self, TypeVar +from typing import Literal as TypingLiteral from urllib.parse import urlparse from pydantic import ( BaseModel, BeforeValidator, ConfigDict, + Discriminator, Field, GetCoreSchemaHandler, PlainSerializer, PlainValidator, + Tag, ) from pydantic.alias_generators import to_camel from pydantic_core import CoreSchema, core_schema @@ -336,6 +339,8 @@ class Initiator(StrEnum): class ExchangeInfo(BaseModel): + """Fields shared by :class:`AskExchangeInfo` and :class:`PostExchangeInfo`.""" + model_config = ConfigDict( alias_generator=to_camel, frozen=True, populate_by_name=True ) @@ -349,13 +354,81 @@ class ExchangeInfo(BaseModel): failed_message: str | None = None +class AskExchangeInfo(ExchangeInfo): + """Exchange with an ANSWER KI of another knowledge base. + + ``binding_set`` holds the raw bindings that KI answered with, in terms of + *its* graph pattern (which may differ from the asking KI's pattern). + """ + + knowledge_interaction_type: TypingLiteral[KiTypes.ANSWER] = KiTypes.ANSWER + binding_set: BindingSet = Field(default_factory=list) + + +class PostExchangeInfo(ExchangeInfo): + """Exchange with a REACT KI of another knowledge base. + + The binding sets are raw and in terms of the reacting KI's graph patterns. + """ + + knowledge_interaction_type: TypingLiteral[KiTypes.REACT] = KiTypes.REACT + argument_binding_set: BindingSet = Field(default_factory=list) + result_binding_set: BindingSet = Field(default_factory=list) + + +type AnyExchangeInfo = AskExchangeInfo | PostExchangeInfo + + +def _exchange_info_tag(default: KiTypes) -> Callable[[Any], str]: + """Build a discriminator that picks the exchange info subtype of an entry. + + KE versions up to 1.5.0 omit ``knowledgeInteractionType`` and only return + ``AskExchangeInfo`` for ASK and ``PostExchangeInfo`` for POST, so fall back + to the binding set fields present, and finally to ``default``. + """ + + def tag(value: Any) -> str: + if not isinstance(value, dict): + return getattr(value, "knowledge_interaction_type", default) + ki_type = value.get( + "knowledgeInteractionType", value.get("knowledge_interaction_type") + ) + if ki_type is not None: + return ki_type + if value.keys() & { + "argumentBindingSet", + "resultBindingSet", + "argument_binding_set", + "result_binding_set", + }: + return KiTypes.REACT + if value.keys() & {"bindingSet", "binding_set"}: + return KiTypes.ANSWER + return default + + return tag + + +_AskResultExchangeInfo = Annotated[ + Annotated[AskExchangeInfo, Tag(KiTypes.ANSWER)] + | Annotated[PostExchangeInfo, Tag(KiTypes.REACT)], + Discriminator(_exchange_info_tag(default=KiTypes.ANSWER)), +] + +_PostResultExchangeInfo = Annotated[ + Annotated[AskExchangeInfo, Tag(KiTypes.ANSWER)] + | Annotated[PostExchangeInfo, Tag(KiTypes.REACT)], + Discriminator(_exchange_info_tag(default=KiTypes.REACT)), +] + + class AskResult(BaseModel): model_config = ConfigDict( alias_generator=to_camel, frozen=True, populate_by_name=True ) binding_set: BindingSet - exchange_info: list[ExchangeInfo] + exchange_info: list[_AskResultExchangeInfo] class PostResult(BaseModel): @@ -364,4 +437,4 @@ class PostResult(BaseModel): ) result_binding_set: BindingSet - exchange_info: list[ExchangeInfo] + exchange_info: list[_PostResultExchangeInfo] diff --git a/src/knowledge_mapper/knowledge_interaction.py b/src/knowledge_mapper/knowledge_interaction.py index d88b184..bcaf0c1 100644 --- a/src/knowledge_mapper/knowledge_interaction.py +++ b/src/knowledge_mapper/knowledge_interaction.py @@ -7,9 +7,9 @@ from .dependency_injection import REQUESTING_KB_ID_PARAM, resolve_dependencies from .ke.models import ( + AnyExchangeInfo, BindingModel, BindingSet, - ExchangeInfo, KiTypes, KnowledgeBaseId, KnowledgeInteraction, @@ -42,12 +42,15 @@ class ExchangeResult[B = BindingModel | dict[str, str]]: Attributes: binding_set: The (result) bindings, parsed into ``BindingModel`` instances when the KI has a binding model, otherwise raw. - exchange_info: One entry per knowledge base the KE exchanged data - with, including its status and timing. + exchange_info: One entry per knowledge interaction the KE exchanged + data with, including its status, timing and raw bindings: an + ``AskExchangeInfo`` for ANSWER KIs and a ``PostExchangeInfo`` for + REACT KIs. Both can occur for either ASK or POST, because the + reasoner may combine KIs of both kinds. """ binding_set: Sequence[B] - exchange_info: list[ExchangeInfo] + exchange_info: list[AnyExchangeInfo] @dataclass diff --git a/src/knowledge_mapper/testing/fake_client.py b/src/knowledge_mapper/testing/fake_client.py index b9322d7..6241266 100644 --- a/src/knowledge_mapper/testing/fake_client.py +++ b/src/knowledge_mapper/testing/fake_client.py @@ -6,15 +6,16 @@ from knowledge_mapper.ke.client import ClientProtocol, HandleRequest, PollResult from knowledge_mapper.ke.errors import SmartConnectorNotFoundError from knowledge_mapper.ke.models import ( + AskExchangeInfo, AskResult, BindingSet, - ExchangeInfo, Initiator, KnowledgeBaseId, KnowledgeBaseInfo, KnowledgeInteraction, KnowledgeInteractionId, KnowledgeInteractionInfo, + PostExchangeInfo, PostResult, SmartConnectorLease, info_from_definition, @@ -231,13 +232,14 @@ async def ask( return AskResult( binding_set=binding_set, exchange_info=[ - ExchangeInfo( + AskExchangeInfo( initiator=Initiator.KNOWLEDGE_BASE, knowledge_base_id=kb_id, knowledge_interaction_id=ki_id, exchange_start=now, exchange_end=now, status="OK", + binding_set=binding_set, ) ], ) @@ -269,13 +271,15 @@ async def post( return PostResult( result_binding_set=result_binding_set, exchange_info=[ - ExchangeInfo( + PostExchangeInfo( initiator=Initiator.KNOWLEDGE_BASE, knowledge_base_id=kb_id, knowledge_interaction_id=ki_id, exchange_start=now, exchange_end=now, status="OK", + argument_binding_set=binding_set, + result_binding_set=result_binding_set, ) ], ) diff --git a/tests/test_ask_and_post.py b/tests/test_ask_and_post.py index dae6f61..108d1b0 100644 --- a/tests/test_ask_and_post.py +++ b/tests/test_ask_and_post.py @@ -1,7 +1,13 @@ import pytest from rdflib import URIRef -from knowledge_mapper import ExchangeInfo, ExchangeResult, KnowledgeBase +from knowledge_mapper import ( + AskExchangeInfo, + ExchangeInfo, + ExchangeResult, + KnowledgeBase, + PostExchangeInfo, +) from knowledge_mapper.ke.models import BindingModel, Literal, Uri from knowledge_mapper.testing import TestClient @@ -262,8 +268,13 @@ async def test_ask_with_info_returns_bindings_and_exchange_info( assert len(result.exchange_info) == 1 info = result.exchange_info[0] assert isinstance(info, ExchangeInfo) + assert isinstance(info, AskExchangeInfo) assert info.knowledge_interaction_id == kb.ki_registry["ask-info-ki"].ke_id assert info.status == "OK" + # Exchange info bindings stay raw: they belong to the answering KI. + assert info.binding_set == [ + {"person": "", "name": '"Alice"^^xsd:string'} + ] async def test_post_with_info_returns_bindings_and_exchange_info( @@ -296,8 +307,15 @@ async def test_post_with_info_returns_bindings_and_exchange_info( ] assert len(result.exchange_info) == 1 info = result.exchange_info[0] + assert isinstance(info, PostExchangeInfo) assert info.knowledge_interaction_id == kb.ki_registry["post-info-ki"].ke_id assert info.status == "OK" + assert info.argument_binding_set == [ + {"person": "", "name": '"Bob"'} + ] + assert info.result_binding_set == [ + {"person": "", "name": '"Bob"^^xsd:string'} + ] async def test_ask_with_info_rejects_non_ask_ki(kb: KnowledgeBase): diff --git a/tests/test_client.py b/tests/test_client.py index b805559..4c5f6c6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from pydantic import ValidationError from knowledge_mapper.ke import Client from knowledge_mapper.ke.errors import ( @@ -10,7 +11,12 @@ from knowledge_mapper.ke.models import ( AskAnswerInteractionInfo, AskAnswerKnowledgeInteraction, + AskExchangeInfo, + Initiator, + KnowledgeBaseId, KnowledgeBaseInfo, + KnowledgeInteractionId, + PostExchangeInfo, PostReactInteractionInfo, SmartConnectorLease, ) @@ -419,3 +425,146 @@ async def test_load_domain_knowledge_not_found(client: Client): kb_id="http://example.org/missing#kb", knowledge="-> ( a b c ) .", ) + + +# ---------- Exchange info + +_KB_ID = KnowledgeBaseId("http://example.org/test#kb") +_KI_ID = KnowledgeInteractionId("http://example.org/test#ki") + +_EXCHANGE_INFO_BASE = { + "initiator": "knowledgeBase", + "knowledgeBaseId": "http://example.org/test#other-kb", + "knowledgeInteractionId": "http://example.org/test#other-ki", + "exchangeStart": "2026-01-01T12:00:00Z", + "exchangeEnd": "2026-01-01T12:00:01Z", + "status": "SUCCEEDED", +} + + +def _ok_response(body: dict) -> MagicMock: + response = MagicMock() + response.is_success = True + response.json.return_value = body + return response + + +async def test_ask_parses_exchange_info_without_discriminator(client: Client): + # KE <= 1.5.0 omits knowledgeInteractionType. + body = { + "bindingSet": [{"a": ""}], + "exchangeInfo": [ + { + **_EXCHANGE_INFO_BASE, + "bindingSet": [{"a": ""}], + } + ], + } + + with patch.object( + client._http, "post", new_callable=AsyncMock, return_value=_ok_response(body) + ): + result = await client.ask( + kb_id=_KB_ID, + ki_id=_KI_ID, + binding_set=[], + ) + + [info] = result.exchange_info + assert isinstance(info, AskExchangeInfo) + assert info.binding_set == [{"a": ""}] + assert info.knowledge_base_id == "http://example.org/test#other-kb" + + +async def test_ask_parses_mixed_exchange_info_with_discriminator(client: Client): + # Newer KEs tag each entry; the reasoner may involve REACT KIs in an ASK. + body = { + "bindingSet": [], + "exchangeInfo": [ + { + **_EXCHANGE_INFO_BASE, + "knowledgeInteractionType": "AnswerKnowledgeInteraction", + "bindingSet": [{"a": ""}], + }, + { + **_EXCHANGE_INFO_BASE, + "initiator": "reasoner", + "knowledgeInteractionType": "ReactKnowledgeInteraction", + "argumentBindingSet": [{"b": ""}], + "resultBindingSet": [{"c": ""}], + }, + ], + } + + with patch.object( + client._http, "post", new_callable=AsyncMock, return_value=_ok_response(body) + ): + result = await client.ask( + kb_id=_KB_ID, + ki_id=_KI_ID, + binding_set=[], + ) + + ask_info, post_info = result.exchange_info + assert isinstance(ask_info, AskExchangeInfo) + assert ask_info.binding_set == [{"a": ""}] + assert isinstance(post_info, PostExchangeInfo) + assert post_info.initiator == Initiator.REASONER + assert post_info.argument_binding_set == [{"b": ""}] + assert post_info.result_binding_set == [{"c": ""}] + + +async def test_post_parses_exchange_info_without_discriminator(client: Client): + body = { + "resultBindingSet": [], + "exchangeInfo": [ + { + **_EXCHANGE_INFO_BASE, + "argumentBindingSet": [{"b": ""}], + "resultBindingSet": [], + }, + # No binding sets at all: falls back to the POST subtype. + {**_EXCHANGE_INFO_BASE, "status": "FAILED", "failedMessage": "Boom"}, + ], + } + + with patch.object( + client._http, "post", new_callable=AsyncMock, return_value=_ok_response(body) + ): + result = await client.post( + kb_id=_KB_ID, + ki_id=_KI_ID, + binding_set=[{"b": ""}], + ) + + with_bindings, without_bindings = result.exchange_info + assert isinstance(with_bindings, PostExchangeInfo) + assert with_bindings.argument_binding_set == [{"b": ""}] + assert with_bindings.result_binding_set == [] + assert isinstance(without_bindings, PostExchangeInfo) + assert without_bindings.argument_binding_set == [] + assert without_bindings.failed_message == "Boom" + + +async def test_ask_rejects_unknown_exchange_info_type(client: Client): + body = { + "bindingSet": [], + "exchangeInfo": [ + {**_EXCHANGE_INFO_BASE, "knowledgeInteractionType": "UnknownInteraction"} + ], + } + + with ( + patch.object( + client._http, + "post", + new_callable=AsyncMock, + return_value=_ok_response(body), + ), + pytest.raises(ValidationError), + ): + await client.ask( + kb_id=_KB_ID, + ki_id=_KI_ID, + binding_set=[], + )