Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
12 changes: 11 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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="...")

Expand All @@ -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
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
10 changes: 9 additions & 1 deletion examples/03-ask_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.")
Expand Down
3 changes: 3 additions & 0 deletions src/knowledge_mapper/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from .kb.builder import KnowledgeBaseBuilder
from .kb.knowledge_base import KnowledgeBase
from .ke.models import (
AnyExchangeInfo,
AskExchangeInfo,
BindingModel,
BindingSet,
Datatype,
Expand All @@ -12,6 +14,7 @@
KnowledgeInteraction,
KnowledgeInteractionId,
Literal,
PostExchangeInfo,
RdfLiteral,
Uri,
validate_kb_id,
Expand Down
79 changes: 76 additions & 3 deletions src/knowledge_mapper/ke/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
)
Expand All @@ -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):
Expand All @@ -364,4 +437,4 @@ class PostResult(BaseModel):
)

result_binding_set: BindingSet
exchange_info: list[ExchangeInfo]
exchange_info: list[_PostResultExchangeInfo]
11 changes: 7 additions & 4 deletions src/knowledge_mapper/knowledge_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions src/knowledge_mapper/testing/fake_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
],
)
Expand Down Expand Up @@ -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,
)
],
)
Expand Down
20 changes: 19 additions & 1 deletion tests/test_ask_and_post.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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": "<http://example.org/test#p1>", "name": '"Alice"^^xsd:string'}
]


async def test_post_with_info_returns_bindings_and_exchange_info(
Expand Down Expand Up @@ -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": "<http://example.org/test#p1>", "name": '"Bob"'}
]
assert info.result_binding_set == [
{"person": "<http://example.org/test#p1>", "name": '"Bob"^^xsd:string'}
]


async def test_ask_with_info_rejects_non_ask_ki(kb: KnowledgeBase):
Expand Down
Loading
Loading