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=[], + )