diff --git a/README.md b/README.md index 21ccf5f..e3b8f0e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The Knowledge Mapper is a Python SDK for connecting your applications to the [TNO Knowledge Engine (TKE)](https://docs.knowledge-engine.eu/) network. Define knowledge interactions with decorators, use typed binding models, and let the SDK handle registration, polling, and data exchange with the network. -![architecture diagram](./docs/img/architecture.png) +The best way to learn to work with the Knowledge Mapper is to check out the [examples](/examples/). Users that are familiar with Python's [FastAPI](https://fastapi.tiangolo.com/) will recognize many concepts from that package. ## Quick Start diff --git a/docs/img/architecture.graphml b/docs/img/architecture.graphml deleted file mode 100644 index 4c3515d..0000000 --- a/docs/img/architecture.graphml +++ /dev/null @@ -1,467 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - TkeClient.py - - - - - - - - - - - KE runtime - - - - - - - - - - - KE REST API - - - - - - - - - - - Knowledge Mapper CLI - - - - - - - - - - - Inter KE runtime protocol - - - - - - - - - - - Other KE runtime - - - - - - - - - - - Java Developer API - - - - - - - - - - - TkeClient.js - - - - - - - - - - - TkeClient.java - - - - - - - - - - - Knowledge Directory protocol - - - - - - - - - - - Knowledge Directory - - - - - - - - - - - Other KE runtime - - - - - - - - - - - python -m knowledge_mapper config.jsonc - - - - - - - - - - - config.jsonc - - - - - - - - - - - { - "knowledge_engine_endpoint": "http://localhost:8280/rest", - "knowledge_base": { - "id": "https://example.org/a-sparql-knowledge-base", - "name": "Some SPARQL knowledge base", - "description": "This is just an example." - }, - "sparql": { - "endpoint": "http://localhost:3030/example" - }, - "knowledge_interactions": [ - { - "type": "answer", - "vars": ["a", "b"], - "pattern": "?a <https://example.org/isRelatedTo> ?b ." - } - ] -} - - - - - - - - - - - SPARQL - - - - - - - - - - - SQL - - - - - - - - - - - Python class - - - - - - - - - - - A - - - - - - - - - - - B - - - - - - - - - - - = - -A initiates -communication -with B - - - - - - - - - - - Defines mapping and configures -connection details for data source - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/img/architecture.png b/docs/img/architecture.png deleted file mode 100644 index 6370258..0000000 Binary files a/docs/img/architecture.png and /dev/null differ diff --git a/src/knowledge_mapper/kb/builder.py b/src/knowledge_mapper/kb/builder.py index 20ea2d3..821662d 100644 --- a/src/knowledge_mapper/kb/builder.py +++ b/src/knowledge_mapper/kb/builder.py @@ -36,6 +36,8 @@ def __init__(self, settings: KnowledgeBaseSettings) -> None: name=settings.knowledge_base.name, description=settings.knowledge_base.description, ke_url=settings.knowledge_engine_endpoint, + lease_renewal_time=settings.knowledge_base.lease_renewal_time, + reasoner_level=settings.knowledge_base.reasoner_level, ) self._unhandled_incoming: set[str] = { ki.name diff --git a/src/knowledge_mapper/kb/knowledge_base.py b/src/knowledge_mapper/kb/knowledge_base.py index 04f51c0..199d600 100644 --- a/src/knowledge_mapper/kb/knowledge_base.py +++ b/src/knowledge_mapper/kb/knowledge_base.py @@ -19,6 +19,7 @@ KnowledgeBaseInfo, KnowledgeInteractionInfo, PostReactInteractionInfo, + SmartConnectorLease, ) from ..knowledge_interaction import ( Handler, @@ -44,7 +45,15 @@ class KnowledgeBase: Starts in unregistered state. """ - def __init__(self, id: str, name: str, description: str, ke_url: str): + def __init__( + self, + id: str, + name: str, + description: str, + ke_url: str, + lease_renewal_time: int | None = None, + reasoner_level: int | None = None, + ): self.state = KnowledgeBaseState.UNREGISTERED self.ki_registry: dict[str, KnowledgeInteractionContext[Any, ...]] = {} self._ki_registry_by_id: dict[str, KnowledgeInteractionContext[Any, ...]] = {} @@ -53,6 +62,8 @@ def __init__(self, id: str, name: str, description: str, ke_url: str): id=id, name=name, description=description, + lease_renewal_time=lease_renewal_time, + reasoner_level=reasoner_level, ) self.dependency_overrides: dict[Callable[..., Any], Callable[..., Any]] = {} @@ -226,7 +237,7 @@ def wrapper( *args, **kwargs, ) -> BindingSet | Sequence[BindingModel]: - return func(binding_set, info, *args, **kwargs) + return func(binding_set, info, *args, **kwargs) # pyright: ignore[reportReturnType] self._register_ki_locally( KnowledgeInteractionContext( @@ -268,6 +279,80 @@ async def sync_knowledge_interactions(self) -> None: self._ki_registry_by_id[ki_ctx.info.id] = ki_ctx return + async def unregister_ki(self, ki_name: str) -> None: + """Unregister a single knowledge interaction by name from this KB at the KE + runtime, and remove it from this object's local registry. + + Raises: + ValueError: If the KB is not registered, or if ``ki_name`` is unknown, or + if the KI is not currently registered at the KE runtime. + SmartConnectorNotFoundError: If the KB's smart connector or the KI is not + found in the KE runtime. + UnexpectedHttpResponseError: If the KE runtime returns an unexpected HTTP + response. + """ + if self.state != KnowledgeBaseState.REGISTERED: + raise ValueError( + f"Cannot unregister KI '{ki_name}' because the KB is not registered." + ) + if ki_name not in self.ki_registry: + raise ValueError( + f"Cannot unregister KI '{ki_name}': no KI with that name is " + f"registered for this KB." + ) + ki_ctx = self.ki_registry[ki_name] + if ( + ki_ctx.status != KnowledgeInteractionStatus.REGISTERED + or ki_ctx.info.id is None + ): + raise ValueError( + f"Cannot unregister KI '{ki_name}' because it is not currently " + f"registered at the KE runtime." + ) + + logger.info("Unregistering KI '%s' (%s).", ki_name, ki_ctx.info.id) + await self.client.unregister_ki(kb_id=self.info.id, ki_id=ki_ctx.info.id) + self._ki_registry_by_id.pop(ki_ctx.info.id, None) + self.ki_registry.pop(ki_name, None) + + async def renew_lease(self) -> SmartConnectorLease: + """Renew this KB's smart connector lease at the KE runtime and return the new + lease. + + Raises: + ValueError: If the KB is not registered. + SmartConnectorNotFoundError: If the KB's smart connector is not found in + the KE runtime, or it does not have a lease. + UnexpectedHttpResponseError: If the KE runtime returns an unexpected HTTP + response. + """ + if self.state != KnowledgeBaseState.REGISTERED: + raise ValueError("Cannot renew lease because the KB is not registered.") + logger.debug("Renewing lease for KB '%s'.", self.info.id) + return await self.client.renew_lease(kb_id=self.info.id) + + async def load_domain_knowledge(self, knowledge: str) -> None: + """Load domain knowledge (Apache Jena facts/rules) into this KB's smart + connector. Replaces any previously loaded domain knowledge. + + Args: + knowledge: The domain knowledge (facts and rules) as plain text in the + Apache Jena Rules syntax. + + Raises: + ValueError: If the KB is not registered. + SmartConnectorNotFoundError: If the KB's smart connector is not found in + the KE runtime. + UnexpectedHttpResponseError: If the KE runtime returns an unexpected HTTP + response. + """ + if self.state != KnowledgeBaseState.REGISTERED: + raise ValueError( + "Cannot load domain knowledge because the KB is not registered." + ) + logger.debug("Loading domain knowledge for KB '%s'.", self.info.id) + await self.client.load_domain_knowledge(kb_id=self.info.id, knowledge=knowledge) + def ki_from_info( self, info: KnowledgeInteractionInfo, diff --git a/src/knowledge_mapper/ke/client.py b/src/knowledge_mapper/ke/client.py index 8ab43eb..a74f095 100644 --- a/src/knowledge_mapper/ke/client.py +++ b/src/knowledge_mapper/ke/client.py @@ -16,6 +16,7 @@ KnowledgeInteractionInfo, PostReactInteractionInfo, PostResult, + SmartConnectorLease, ) logger = logging.getLogger(__name__) @@ -122,6 +123,48 @@ async def register_ki( """ ... + async def unregister_ki(self, kb_id: str, ki_id: str) -> None: + """Unregister a single knowledge interaction with the given ID from the KB + with the given ID. + + Raises: + SmartConnectorNotFoundError: If no smart connector exists for the given KB + ID, or no knowledge interaction with the given ID exists for that KB. + UnexpectedHttpResponseError: If the KE runtime returns an unexpected HTTP + response. + """ + ... + + async def renew_lease(self, kb_id: str) -> SmartConnectorLease: + """Renew the lease of the smart connector for the given KB and return the + new lease. + + Raises: + SmartConnectorNotFoundError: If no smart connector exists for the given KB + ID, or it does not have a lease. + UnexpectedHttpResponseError: If the KE runtime returns an unexpected HTTP + response. + """ + ... + + async def load_domain_knowledge(self, kb_id: str, knowledge: str) -> None: + """Load domain knowledge (Apache Jena facts/rules) into the smart connector + for the given KB. Replaces any previously loaded domain knowledge. + + Args: + kb_id: The ID of the KB whose smart connector should be loaded with the + given domain knowledge. + knowledge: The domain knowledge (both facts and rules) as plain text in + the Apache Jena Rules syntax. + + Raises: + SmartConnectorNotFoundError: If no smart connector exists for the given KB + ID. + UnexpectedHttpResponseError: If the KE runtime returns an unexpected HTTP + response. + """ + ... + async def poll_ki_call(self, kb_id: str) -> tuple[PollResult, HandleRequest | None]: """Poll the KE runtime for an incoming KI call for the given KB. @@ -247,7 +290,7 @@ async def register_kb( logger.debug("Registering knowledge base '%s' at %s.", info.id, self.ke_url) response = await self._http.post( f"{self.ke_url}/sc", - json=info.model_dump(by_alias=True), + json=info.model_dump(by_alias=True, exclude_none=True), ) if not response.is_success: raise UnexpectedHttpResponseError(response) @@ -309,6 +352,53 @@ async def register_ki( ) return registered_ki + async def unregister_ki(self, kb_id: str, ki_id: str) -> None: + logger.debug( + "Unregistering knowledge interaction '%s' for KB '%s' at %s.", + ki_id, + kb_id, + self.ke_url, + ) + response = await self._http.delete( + f"{self.ke_url}/sc/ki", + headers={ + "Knowledge-Base-Id": kb_id, + "Knowledge-Interaction-Id": ki_id, + }, + ) + if response.status_code == 404: + raise SmartConnectorNotFoundError(kb_id, self.ke_url) + if not response.is_success: + raise UnexpectedHttpResponseError(response) + + async def renew_lease(self, kb_id: str) -> SmartConnectorLease: + logger.debug("Renewing lease for KB '%s' at %s.", kb_id, self.ke_url) + response = await self._http.put( + f"{self.ke_url}/sc/lease/renew", + headers={"Knowledge-Base-Id": kb_id}, + ) + if response.status_code == 404: + raise SmartConnectorNotFoundError(kb_id, self.ke_url) + if not response.is_success: + raise UnexpectedHttpResponseError(response) + + return SmartConnectorLease.model_validate(response.json()) + + async def load_domain_knowledge(self, kb_id: str, knowledge: str) -> None: + logger.debug("Loading domain knowledge for KB '%s' at %s.", kb_id, self.ke_url) + response = await self._http.post( + f"{self.ke_url}/sc/knowledge", + content=knowledge.encode("utf-8"), + headers={ + "Knowledge-Base-Id": kb_id, + "Content-Type": "text/plain; charset=UTF-8", + }, + ) + if response.status_code == 404: + raise SmartConnectorNotFoundError(kb_id, self.ke_url) + if not response.is_success: + raise UnexpectedHttpResponseError(response) + async def poll_ki_call(self, kb_id: str) -> tuple[PollResult, HandleRequest | None]: logger.debug("Polling for KI calls...") response = await self._http.get( diff --git a/src/knowledge_mapper/ke/models.py b/src/knowledge_mapper/ke/models.py index 87e21b9..534fc92 100644 --- a/src/knowledge_mapper/ke/models.py +++ b/src/knowledge_mapper/ke/models.py @@ -114,6 +114,21 @@ class KnowledgeBaseInfo(BaseModel): id: Annotated[str, Field(..., alias="knowledgeBaseId")] name: Annotated[str, Field(..., alias="knowledgeBaseName")] description: Annotated[str, Field(..., alias="knowledgeBaseDescription")] + lease_renewal_time: Annotated[ + int | None, Field(default=None, alias="leaseRenewalTime", ge=30, le=3600) + ] = None + reasoner_level: Annotated[ + int | None, Field(default=None, alias="reasonerLevel", ge=1, le=5) + ] = None + + +class SmartConnectorLease(BaseModel): + model_config = ConfigDict( + alias_generator=to_camel, frozen=True, populate_by_name=True + ) + + knowledge_base_id: str + expires: datetime class KiTypes(StrEnum): diff --git a/src/knowledge_mapper/testing/fake_client.py b/src/knowledge_mapper/testing/fake_client.py index aa79ed0..02aa4f1 100644 --- a/src/knowledge_mapper/testing/fake_client.py +++ b/src/knowledge_mapper/testing/fake_client.py @@ -1,9 +1,10 @@ """In-memory FakeClient that satisfies ClientProtocol for use in tests.""" import asyncio -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from knowledge_mapper.ke.client import ClientProtocol, HandleRequest, PollResult +from knowledge_mapper.ke.errors import SmartConnectorNotFoundError from knowledge_mapper.ke.models import ( AskResult, BindingSet, @@ -12,6 +13,7 @@ KnowledgeBaseInfo, KnowledgeInteractionInfo, PostResult, + SmartConnectorLease, ) @@ -31,6 +33,10 @@ def __init__(self, fake_url) -> None: asyncio.Queue() ) self._next_handle_request_id: int = 1 + # Maps kb_id -> the most recently loaded domain knowledge string. + self._domain_knowledge: dict[str, str] = {} + # Maps kb_id -> number of times its lease has been renewed. + self._lease_renewals: dict[str, int] = {} async def ke_is_available(self) -> bool: return True @@ -72,6 +78,43 @@ async def register_ki( self._knowledge_interactions.setdefault(kb_id, []).append(registered) return registered + async def unregister_ki(self, kb_id: str, ki_id: str) -> None: + if kb_id not in self._knowledge_bases: + raise SmartConnectorNotFoundError(kb_id, self._ke_url) + kis = self._knowledge_interactions.get(kb_id, []) + for i, ki in enumerate(kis): + if ki.id == ki_id: + kis.pop(i) + return + raise SmartConnectorNotFoundError(kb_id, self._ke_url) + + async def renew_lease(self, kb_id: str) -> SmartConnectorLease: + if kb_id not in self._knowledge_bases: + raise SmartConnectorNotFoundError(kb_id, self._ke_url) + self._lease_renewals[kb_id] = self._lease_renewals.get(kb_id, 0) + 1 + info = self._knowledge_bases[kb_id] + # Default to 60 seconds if leaseRenewalTime is unset. + renewal_seconds = info.lease_renewal_time or 60 + return SmartConnectorLease( + knowledge_base_id=kb_id, + expires=datetime.now(tz=UTC) + timedelta(seconds=renewal_seconds), + ) + + async def load_domain_knowledge(self, kb_id: str, knowledge: str) -> None: + if kb_id not in self._knowledge_bases: + raise SmartConnectorNotFoundError(kb_id, self._ke_url) + self._domain_knowledge[kb_id] = knowledge + + @property + def loaded_domain_knowledge(self) -> dict[str, str]: + """Return the most-recently-loaded domain knowledge per KB id (for tests).""" + return dict(self._domain_knowledge) + + @property + def lease_renewals(self) -> dict[str, int]: + """Return the number of lease renewals per KB id (for tests).""" + return dict(self._lease_renewals) + async def poll_ki_call(self, kb_id: str) -> tuple[PollResult, HandleRequest | None]: return await self._incoming_calls.get() diff --git a/tests/test_client.py b/tests/test_client.py index 157122c..83f2f0e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,11 +3,16 @@ import pytest from knowledge_mapper.ke import Client +from knowledge_mapper.ke.errors import ( + SmartConnectorNotFoundError, + UnexpectedHttpResponseError, +) from knowledge_mapper.ke.models import ( AskAnswerInteractionInfo, KnowledgeBaseInfo, KnowledgeInteractionInfo, PostReactInteractionInfo, + SmartConnectorLease, ) @@ -178,3 +183,239 @@ async def test_register_knowledge_interaction(client: Client): ) assert registered_ki.id == "http://example.org/test#kb/interaction/ask-interaction" + + +async def test_register_knowledge_base_with_optional_fields(client: Client): + """leaseRenewalTime / reasonerLevel are sent only when set (no None in payload).""" + mock_get_response = MagicMock() + mock_get_response.status_code = 404 + + mock_post_response = MagicMock() + mock_post_response.is_success = True + + with ( + patch.object( + client._http, + "get", + new_callable=AsyncMock, + return_value=mock_get_response, + ), + patch.object( + client._http, + "post", + new_callable=AsyncMock, + return_value=mock_post_response, + ) as mock_post, + ): + await client.register_kb( + info=KnowledgeBaseInfo( + id="http://example.org/test#kb", + name="test-kb", + description="A KB for testing.", + lease_renewal_time=60, + reasoner_level=4, + ) + ) + + mock_post.assert_called_once_with( + "http://fake-ke/sc", + json={ + "knowledgeBaseId": "http://example.org/test#kb", + "knowledgeBaseName": "test-kb", + "knowledgeBaseDescription": "A KB for testing.", + "leaseRenewalTime": 60, + "reasonerLevel": 4, + }, + ) + + +async def test_register_knowledge_base_omits_unset_optional_fields(client: Client): + """Optional fields must NOT appear in the request payload when unset.""" + mock_get_response = MagicMock() + mock_get_response.status_code = 404 + + mock_post_response = MagicMock() + mock_post_response.is_success = True + + with ( + patch.object( + client._http, + "get", + new_callable=AsyncMock, + return_value=mock_get_response, + ), + patch.object( + client._http, + "post", + new_callable=AsyncMock, + return_value=mock_post_response, + ) as mock_post, + ): + await client.register_kb( + info=KnowledgeBaseInfo( + id="http://example.org/test#kb", + name="test-kb", + description="A KB for testing.", + ) + ) + + payload = mock_post.call_args.kwargs["json"] + assert "leaseRenewalTime" not in payload + assert "reasonerLevel" not in payload + + +async def test_unregister_knowledge_interaction(client: Client): + mock_response = MagicMock() + mock_response.is_success = True + mock_response.status_code = 200 + + with patch.object( + client._http, + "delete", + new_callable=AsyncMock, + return_value=mock_response, + ) as mock_delete: + await client.unregister_ki( + kb_id="http://example.org/test#kb", + ki_id="http://example.org/test#kb/interaction/ask-interaction", + ) + + mock_delete.assert_called_once_with( + "http://fake-ke/sc/ki", + headers={ + "Knowledge-Base-Id": "http://example.org/test#kb", + "Knowledge-Interaction-Id": ( + "http://example.org/test#kb/interaction/ask-interaction" + ), + }, + ) + + +async def test_unregister_knowledge_interaction_not_found(client: Client): + mock_response = MagicMock() + mock_response.is_success = False + mock_response.status_code = 404 + + with ( + patch.object( + client._http, + "delete", + new_callable=AsyncMock, + return_value=mock_response, + ), + pytest.raises(SmartConnectorNotFoundError), + ): + await client.unregister_ki( + kb_id="http://example.org/missing#kb", + ki_id="http://example.org/missing#kb/interaction/x", + ) + + +async def test_unregister_knowledge_interaction_unexpected_response(client: Client): + mock_response = MagicMock() + mock_response.is_success = False + mock_response.status_code = 500 + + with ( + patch.object( + client._http, + "delete", + new_callable=AsyncMock, + return_value=mock_response, + ), + pytest.raises(UnexpectedHttpResponseError), + ): + await client.unregister_ki( + kb_id="http://example.org/test#kb", + ki_id="http://example.org/test#kb/interaction/x", + ) + + +async def test_renew_lease(client: Client): + mock_response = MagicMock() + mock_response.is_success = True + mock_response.status_code = 200 + mock_response.json.return_value = { + "knowledgeBaseId": "http://example.org/test#kb", + "expires": "2026-06-25T12:00:00+00:00", + } + + with patch.object( + client._http, + "put", + new_callable=AsyncMock, + return_value=mock_response, + ) as mock_put: + lease = await client.renew_lease("http://example.org/test#kb") + + mock_put.assert_called_once_with( + "http://fake-ke/sc/lease/renew", + headers={"Knowledge-Base-Id": "http://example.org/test#kb"}, + ) + assert isinstance(lease, SmartConnectorLease) + assert lease.knowledge_base_id == "http://example.org/test#kb" + + +async def test_renew_lease_not_found(client: Client): + mock_response = MagicMock() + mock_response.is_success = False + mock_response.status_code = 404 + + with ( + patch.object( + client._http, + "put", + new_callable=AsyncMock, + return_value=mock_response, + ), + pytest.raises(SmartConnectorNotFoundError), + ): + await client.renew_lease("http://example.org/missing#kb") + + +async def test_load_domain_knowledge(client: Client): + mock_response = MagicMock() + mock_response.is_success = True + mock_response.status_code = 200 + + knowledge = "-> ( saref:Sensor rdfs:subClassOf saref:Device ) ." + + with patch.object( + client._http, + "post", + new_callable=AsyncMock, + return_value=mock_response, + ) as mock_post: + await client.load_domain_knowledge( + kb_id="http://example.org/test#kb", + knowledge=knowledge, + ) + + mock_post.assert_called_once_with( + "http://fake-ke/sc/knowledge", + content=knowledge.encode("utf-8"), + headers={ + "Knowledge-Base-Id": "http://example.org/test#kb", + "Content-Type": "text/plain; charset=UTF-8", + }, + ) + + +async def test_load_domain_knowledge_not_found(client: Client): + mock_response = MagicMock() + mock_response.is_success = False + mock_response.status_code = 404 + + with ( + patch.object( + client._http, + "post", + new_callable=AsyncMock, + return_value=mock_response, + ), + pytest.raises(SmartConnectorNotFoundError), + ): + await client.load_domain_knowledge( + kb_id="http://example.org/missing#kb", + knowledge="-> ( a b c ) .", + ) diff --git a/tests/test_kb_new_operations.py b/tests/test_kb_new_operations.py new file mode 100644 index 0000000..83170e8 --- /dev/null +++ b/tests/test_kb_new_operations.py @@ -0,0 +1,119 @@ +"""Tests for KnowledgeBase wrappers around the three new client operations: + +- ``unregister_ki(ki_name)`` +- ``renew_lease()`` +- ``load_domain_knowledge(knowledge)`` +""" + +import pytest + +from knowledge_mapper import KnowledgeBase +from knowledge_mapper.ke.errors import SmartConnectorNotFoundError +from knowledge_mapper.ke.models import ( + KnowledgeInteractionInfo, + SmartConnectorLease, +) +from knowledge_mapper.testing import TestClient + + +def _kb() -> KnowledgeBase: + kb = KnowledgeBase( + id="http://example.org/test#kb", + name="test-kb", + description="A KB for testing.", + ke_url="http://fake-ke", + ) + kb.client = TestClient(fake_url="http://fake-ke") + return kb + + +async def test_unregister_ki_removes_from_registries_and_calls_client(): + kb = _kb() + await kb.register() + await kb.register_ki( + ki_ctx=_ki_ctx("ask-it"), + ) + assert "ask-it" in kb.ki_registry + ki_id = kb.ki_registry["ask-it"].info.id + assert ki_id is not None and ki_id in kb._ki_registry_by_id + + await kb.unregister_ki("ask-it") + + assert "ask-it" not in kb.ki_registry + assert ki_id not in kb._ki_registry_by_id + assert await kb.client.get_all_knowledge_interactions(kb.info.id) == [] + + +async def test_unregister_ki_raises_when_kb_not_registered(): + kb = _kb() + with pytest.raises(ValueError, match="not registered"): + await kb.unregister_ki("ask-it") + + +async def test_unregister_ki_raises_for_unknown_ki(): + kb = _kb() + await kb.register() + with pytest.raises(ValueError, match="no KI with that name"): + await kb.unregister_ki("does-not-exist") + + +async def test_renew_lease_returns_lease_and_invokes_client(): + kb = _kb() + await kb.register() + + lease = await kb.renew_lease() + + assert isinstance(lease, SmartConnectorLease) + assert lease.knowledge_base_id == kb.info.id + # The TestClient tracks how often renew_lease was called per KB id. + assert kb.client.lease_renewals[kb.info.id] == 1 # pyright: ignore[reportAttributeAccessIssue] + + +async def test_renew_lease_unknown_kb_raises(): + kb = _kb() + await kb.register() + # Simulate the KB disappearing from the runtime. + await kb.client.unregister_kb(kb.info.id) + with pytest.raises(SmartConnectorNotFoundError): + await kb.renew_lease() + + +async def test_renew_lease_when_kb_not_registered_raises(): + kb = _kb() + with pytest.raises(ValueError, match="not registered"): + await kb.renew_lease() + + +async def test_load_domain_knowledge_stores_payload(): + kb = _kb() + await kb.register() + + knowledge = "-> ( saref:Sensor rdfs:subClassOf saref:Device) ." + await kb.load_domain_knowledge(knowledge) + + assert kb.client.loaded_domain_knowledge[kb.info.id] == knowledge # pyright: ignore[reportAttributeAccessIssue] + + +async def test_load_domain_knowledge_when_kb_not_registered_raises(): + kb = _kb() + with pytest.raises(ValueError, match="not registered"): + await kb.load_domain_knowledge("-> ( a b c ) .") + + +def _ki_ctx(name: str): + """Build a registered ASK-style KI context for use in unregister tests.""" + from knowledge_mapper.ke.models import AskAnswerInteractionInfo, KiTypes + from knowledge_mapper.knowledge_interaction import ( + KnowledgeInteractionContext, + KnowledgeInteractionStatus, + ) + + return KnowledgeInteractionContext[KnowledgeInteractionInfo, ...]( + info=AskAnswerInteractionInfo( + type=KiTypes.ASK, + name=name, + graph_pattern="?s ?p ?o . ", + ), + handler=None, + status=KnowledgeInteractionStatus.UNREGISTERED, + )