diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..692792b --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,387 @@ +# Knowledge Mapper — Codebase Context + +> This file is the authoritative context document for AI coding assistants and developers working on this repository. Keep it up to date as the project evolves. + +--- + +## What Is This Project? + +**`knowledge-mapper`** is a Python SDK for connecting Python applications to the [TNO Knowledge Engine (TKE)](https://docs.knowledge-engine.eu/) network. It provides: + +1. **A Python SDK** — write Python code, use decorators, implement your own handlers. +2. **A settings/config-driven approach** (work in progress) — for simple cases where no custom handler logic is needed, a config file (YAML/JSON) plus a CLI should suffice. + +Both approaches are planned and partially implemented. The SDK path is fully functional; the config-driven CLI path is tracked under issue #10. + +--- + +## TKE Ecosystem Glossary + +| Term | Abbreviation | Meaning | +|------|-------------|---------| +| Knowledge Engine | KE | The distributed network/platform that enables knowledge exchange between applications | +| Knowledge Directory | KD | Central service that matches and routes knowledge interactions between Smart Connectors | +| Smart Connector | SC | A Java runtime process (usually in a container) that acts as a broker between a KB and the KE network; exposes a REST API | +| Knowledge Base | KB | A user's application that holds or requests knowledge; connects to the KE network via a Smart Connector | +| Knowledge Interaction | KI | A declared intent to exchange knowledge — either to provide it (ANSWER/REACT) or request it (ASK/POST) | +| Binding Set | BS | A list of dictionaries mapping SPARQL variable names to RDF N3-encoded values | +| Graph Pattern | GP | A SPARQL-like triple pattern string that describes the "shape" of a knowledge interaction | + +### Knowledge Interaction Types + +| Type | Direction | Role | +|------|-----------|------| +| **ASK** | Outgoing | The KB queries the KE network for knowledge matching the graph pattern | +| **ANSWER** | Incoming | The KB responds to queries from the KE network with its knowledge | +| **POST** | Outgoing | The KB pushes data into the KE network (argument + optional result pattern) | +| **REACT** | Incoming | The KB handles incoming POST calls from the KE network | + +--- + +## Architecture + +``` +User's Python app (this library) + │ + │ instantiates + ▼ + KnowledgeBase (src/kb/knowledge_base.py) + │ + │ REST API calls (requests) + ▼ + Smart Connector (SC) ← Java process, usually in Docker + │ + │ KE network protocol + ▼ + Knowledge Directory (KD) + │ + │ routes to other SCs/KBs + ▼ + Other KBs in the network +``` + +**Key runtime model**: The `KnowledgeBase` registers itself and its KIs with the SC, then enters a **long-polling loop** (`start_handling_loop()`). On each poll the SC either returns an incoming KI call to handle or asks to re-poll. The KB dispatches calls to registered handler functions, serializes the result, and replies to the SC. For outgoing interactions (`ask()` / `post()`), the KB sends a request to the SC which fans out through the network. + +--- + +## Repository Layout + +``` +src/ + __init__.py # Public API exports: KnowledgeBase, KnowledgeBaseBuilder, KnowledgeBaseSettings + knowledge_base.py # KnowledgeBase class — the main user-facing class + knowledge_base_builder.py # KnowledgeBaseBuilder — settings-aware builder that wraps KnowledgeBase + knowledge_interaction.py # KnowledgeInteractionContext, Handler type, status enum + settings.py # KnowledgeBaseSettings (Pydantic BaseSettings subclass) + ke/ + __init__.py + client.py # Client (real HTTP) + ClientProtocol (interface) + PollResult + models.py # All Pydantic models: BindingModel, Uri, Literal, KiTypes, etc. + errors.py # Custom exceptions + testing/ + fake_client.py # TestClient — in-memory fake SC for unit tests + +examples/ + basic.py # Simplest possible KB with an ANSWER KI + binding_models.py # Typed BindingModels vs raw BindingSet usage + ask_interaction.py # ASK KI with a typed BindingModel + post_measurement.py # POST KI with argument and result BindingModels + custom-settings/ + custom_settings.py # KnowledgeBaseSettings subclass + ki_from_settings pattern + settings.yaml # Example YAML config for all four KI types + shared.py # Example logging helper + compose.yaml # Docker Compose for running two SC instances for examples/testing + legacy/ # ← IGNORE: pre-overhaul examples, do not modify + +src/legacy/ # ← IGNORE: pre-overhaul implementation, do not modify + +tests/ + test_ask_and_post.py + test_bindings.py + test_client.py + test_handlers.py + test_kb_lifespan.py + test_ki_registration.py + configuration/ # Config files used by tests +``` + +--- + +## Public API + +### `KnowledgeBase` + +```python +from src import KnowledgeBase + +kb = KnowledgeBase( + id="http://example.org/my-kb", # URI identifying this KB in the network + name="my-kb", + description="...", + ke_url="http://localhost:8280/rest", # URL of the Smart Connector REST API +) + +# Alternatively, build from settings — returns a KnowledgeBaseBuilder: +builder = KnowledgeBase.from_settings(settings) # settings: KnowledgeBaseSettings +``` + +#### Lifecycle +```python +kb.connect() # Verify SC is reachable (raises KnowledgeEngineNotAvailableError if not) +kb.register() # Register KB + sync all KIs with the SC (re-registers if already registered) +kb.unregister() # Unregister KB from SC (KIs automatically unregistered) +``` + +#### Registering KIs (decorator pattern) + +```python +# ANSWER KI — handler called when another KB asks for this pattern +@kb.answer_ki(name="...", graph_pattern="...", prefixes={...}) +def my_handler(binding_set, info): + ... + return binding_set + +# REACT KI — handler called when another KB posts matching data +@kb.react_ki(name="...", argument_graph_pattern="...", result_graph_pattern="...", prefixes={...}) +def my_react_handler(binding_set, info): + ... + return result_binding_set +``` + +#### Registering KIs (non-decorator) + +```python +# ASK KI — no handler; call kb.ask() to query the network +kb.ask_ki(name="...", graph_pattern="...", binding_model=MyModel, prefixes={...}) + +# POST KI — no handler; call kb.post() to push data to the network +kb.post_ki(name="...", argument_graph_pattern="...", result_graph_pattern="...", prefixes={...}) +``` + +#### Outgoing interactions + +```python +result = kb.ask(binding_set, ki_name="...") # Returns BindingSet or list[BindingModel] +result = kb.post(binding_set, ki_name="...") # Returns result BindingSet or list[BindingModel] +``` + +#### Handling loop + +```python +kb.start_handling_loop() # Blocks, handles incoming KIs forever +kb.start_handling_loop(loops=10) # Runs exactly 10 poll iterations (useful for testing) +``` + +--- + +### `KnowledgeBaseBuilder` + +Returned by `KnowledgeBase.from_settings()`. Wraps a `KnowledgeBase` internally and exposes +settings-based KI registration. Call `build()` when all handlers are attached to get the +finished KB. + +ASK and POST KIs are registered automatically from settings — no explicit call needed. +Attach handlers for ANSWER and REACT KIs via `handler()` before calling `build()`. + +```python +from src import KnowledgeBase, KnowledgeBaseSettings + +settings = KnowledgeBaseSettings(...) +builder = KnowledgeBase.from_settings(settings) + +# For ANSWER/REACT — attach a handler; KI info comes from settings +builder.handler("my-answer-ki", my_handler_func) + +# For ASK/POST — no call needed; they are auto-registered from settings + +kb = builder.build() # Returns the configured KnowledgeBase; raises ValueError if any + # ANSWER/REACT KI has no handler +kb.connect() +kb.register() +kb.start_handling_loop() +``` + +--- + +## BindingModel — Typed Bindings + +`BindingModel` (Pydantic `BaseModel` subclass) maps Python types to RDF N3 encoding automatically. Use it to avoid manual N3 string construction. + +```python +from src.ke.models import BindingModel, Uri, Literal +from rdflib import URIRef + +class PersonBinding(BindingModel): + person: Uri # maps to/from URIRef, serialized as <...> + name: Literal[str] # maps to/from Python str, serialized as "..."^^xsd:string + age: Literal[int] # maps to/from Python int, serialized as "..."^^xsd:integer +``` + +- **`Uri`**: Accepts `URIRef` or N3-encoded string (`<...>`), serializes to N3 `<...>`. +- **`Literal[T]`**: Accepts Python native types or N3 literals, serializes to N3 `"value"^^type`. +- All fields default to `None` — use `dump_result_binding()` to validate all fields are set before returning, or `dump_partial_binding()` for partial/query bindings. + +**When to use typed BindingModels vs raw `BindingSet` (list of dicts)**: +- Use `BindingModel` when you want type safety, validation, and automatic N3 serialization. +- Use raw `BindingSet = Sequence[dict[str, str]]` when working with passthrough data or when you need the raw N3 strings. + +Handler type annotation controls automatic (de)serialization: + +```python +# Typed — framework validates incoming bindings and serializes outgoing ones +def my_handler(binding_set: list[PersonBinding], info) -> list[PersonBinding]: ... + +# Raw — no automatic conversion, you get/return raw N3 strings +def my_handler(binding_set: BindingSet, info) -> BindingSet: ... +``` + +--- + +## Settings System (`KnowledgeBaseSettings`) + +Pydantic `BaseSettings` subclass. Supports config from (highest priority first): +1. Keyword arguments +2. Environment variables (delimiter `__` for nested, e.g. `KNOWLEDGE_BASE__ID`) +3. YAML config file (default `config.yaml`) +4. JSON config file (default `config.json`) +5. Field defaults + +Subclass to add application-specific settings: + +```python +from src import KnowledgeBaseSettings +from pydantic_settings import SettingsConfigDict, CliSettingsSource + +class AppSettings(KnowledgeBaseSettings): + model_config = SettingsConfigDict(yaml_file="config.yaml", cli_parse_args=True) + db_url: str = "sqlite:///./app.db" + + @classmethod + def settings_customise_sources(cls, settings_cls, **kwargs): + return (CliSettingsSource(settings_cls, cli_parse_args=True), + *super().settings_customise_sources(settings_cls, **kwargs)) +``` + +YAML config structure: + +```yaml +knowledge_base: + id: "http://example.org/my-kb" + name: "my-kb" + description: "..." +knowledge_engine_endpoint: "http://localhost:8280/rest" +knowledge_interactions: + - name: my-answer-ki + type: AnswerKnowledgeInteraction + prefixes: + ex: "http://example.org/" + graph_pattern: "?s ?p ?o ." +``` + +#### Registering KIs from settings + +```python +builder = KnowledgeBase.from_settings(settings) + +# For ANSWER/REACT — attach a handler function +builder.handler("my-answer-ki", my_handler_func) + +# For ASK/POST — auto-registered; no explicit call needed + +kb = builder.build() +``` + +--- + +## Testing + +Tests use `TestClient` — an in-memory fake Smart Connector satisfying `ClientProtocol`. No live KE runtime needed. + +```python +from src.ke.testing import TestClient + +client = TestClient(fake_url="http://fake-ke") +kb = KnowledgeBase(id="...", name="...", description="...", ke_url="http://fake-ke") +kb.client = client # inject fake client +kb.register() + +# Mock a result for an ASK or POST KI +client.mock_result_binding_set(ki_name="my-ask-ki", binding_set=[...]) + +# Simulate an incoming KI call (ANSWER/REACT) — queued for the handling loop to consume +client.enqueue_handle_request(ki_name="my-answer-ki", binding_set=[...]) +client.enqueue_exit() # signal the handling loop to stop after draining the queue +kb.start_handling_loop() + +# Assert the handler result was posted back to the (fake) SC +assert client.last_handle_response == [...] +``` + +Run tests with: + +```bash +uv run pytest +``` + +For integration tests requiring a live SC, use the Docker Compose in `examples/compose.yaml`: + +```bash +docker compose -f examples/compose.yaml up -d +uv run pytest +``` + +--- + +## Development Environment + +- **Python**: ≥ 3.13 +- **Package manager**: `uv` (see `uv.lock`) +- **Linter/formatter**: `ruff` (configured in `pyproject.toml`, legacy dirs excluded) +- **Build system**: `setuptools` + +```bash +uv sync # install dependencies +uv run pytest # run tests +uv run ruff check . # lint +uv run ruff format . # format +``` + +--- + +## Legacy Code + +**Do not modify or reference code in these directories:** + +- `src/legacy/` — the pre-overhaul mapper implementation (config-file-driven SQL/SPARQL mapper) +- `examples/legacy/` — examples for the legacy implementation + +These are excluded from linting (`ruff`) and are kept for historical reference only. The `mapper-legacy` git tag marks the last legacy release. + +--- + +## Open Issues (GitHub) + +| # | Title | Notes | +|---|-------|-------| +| [#5](https://github.com/TNO/knowledge-mapper/issues/5) | Make `result_pattern` optional for POST interactions | `PostReactInteractionInfo.result_graph_pattern` is currently required; should be optional | +| [#6](https://github.com/TNO/knowledge-mapper/issues/6) | Allow domain knowledge loading via KE client | Extend `Client`/`ClientProtocol` with methods to load domain knowledge into the SC (supported since KE 1.3.1) | +| [#10](https://github.com/TNO/knowledge-mapper/issues/10) | Create simple CLI for starting KM | Entry point for the config-driven approach; part of the "both SDK and config-driven" roadmap | +| [#11](https://github.com/TNO/knowledge-mapper/issues/11) | Add dependency injection system | Allow handlers to declare dependencies (e.g. DB connections) that are injected at call time | +| [#15](https://github.com/TNO/knowledge-mapper/issues/15) | Create default handlers for POST and ASK interactions | ASK/POST KIs are now auto-registered from settings via `builder.build()` with no handler; outgoing-only KIs need no handler | +| [#23](https://github.com/TNO/knowledge-mapper/issues/23) | Extract settings-based KI registration out of KnowledgeBase | ✅ Done — moved to `KnowledgeBaseBuilder` in `src/knowledge_base_builder.py` | +| [#20](https://github.com/TNO/knowledge-mapper/issues/20) | Deepen KnowledgeInteractionContext: move binding dispatch into the context | Binding model apply-logic is duplicated across `call()`, `ask()`, `post()` | +| [#21](https://github.com/TNO/knowledge-mapper/issues/21) | Bug: handling loop dispatches by KI ID but registry is keyed by name; handle response never sent | Latent bug masked by TestClient always returning REPOLL | +| [#22](https://github.com/TNO/knowledge-mapper/issues/22) | TestClient: support enqueueing incoming KI calls to make the handling loop unit-testable | `poll_ki_call` hardwired to REPOLL; HANDLE/EXIT paths untestable | +| [#23](https://github.com/TNO/knowledge-mapper/issues/23) | Extract settings-based KI registration out of KnowledgeBase | Move to `KnowledgeBaseBuilder` in `src/kb/builder.py` | + +--- + +## Key Design Decisions + +- **`KnowledgeBase` ≠ Smart Connector**: The SC is a separate Java process (usually containerized). `KnowledgeBase` is the Python representation of a KB that registers with the SC over REST. +- **Pydantic throughout**: Models, settings, and binding validation all use Pydantic v2. `BindingModel` uses `alias_generator=to_camel` to match the TKE REST API's camelCase fields. +- **`ClientProtocol`**: The `Client` (real HTTP) and `TestClient` (fake) both satisfy this Protocol. Injecting a fake client is the standard testing pattern. `ClientProtocol` includes `post_handle_response` — the method that sends a handler's result back to the SC after an incoming KI call. +- **Deferred KI registration**: By default, `answer_ki`/`react_ki` etc. use `defer_ke_registration=True`, meaning KIs are registered locally but not sent to the SC until `kb.register()` or `kb.sync_knowledge_interactions()` is called. +- **KI registry indexed by ID after registration**: `KnowledgeBase` maintains a secondary index (`_ki_registry_by_id`) populated once a KI is registered with the SC and assigned an ID. The handling loop dispatches by ID using this index. +- **Handler introspection**: `KnowledgeInteractionContext.__post_init__` inspects handler signatures to auto-detect binding models, enabling transparent (de)serialization without manual type dispatch. Dispatch logic (validate → call → serialize for ANSWER/REACT; prepare_outgoing + parse_result for ASK/POST) lives in `KnowledgeInteractionContext`, not in `KnowledgeBase`. +- **`KnowledgeBaseBuilder` wraps `KnowledgeBase`**: Settings-based KI registration belongs to `KnowledgeBaseBuilder`, not to `KnowledgeBase`. `KnowledgeBase.from_settings()` returns a builder; `builder.build()` returns the finished `KnowledgeBase`. `KnowledgeBase` itself has no knowledge of settings. ASK/POST KIs are auto-registered at `build()` time; ANSWER/REACT KIs require a handler attached via `builder.handler(name, func)` before `build()` is called. diff --git a/examples/basic.py b/examples/basic.py index 9a234f4..55e83dd 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -1,6 +1,6 @@ from shared import get_example_logger -from src.knowledge_base import KnowledgeBase +from src.kb.knowledge_base import KnowledgeBase EXAMPLE_NAME = "basic" logger = get_example_logger(EXAMPLE_NAME) diff --git a/examples/binding_models.py b/examples/binding_models.py index ada9d4d..a423668 100644 --- a/examples/binding_models.py +++ b/examples/binding_models.py @@ -3,6 +3,7 @@ from rdflib import URIRef from shared import get_example_logger +from src.kb.knowledge_base import KnowledgeBase from src.ke.models import ( BindingModel, BindingSet, @@ -10,7 +11,6 @@ Literal, Uri, ) -from src.knowledge_base import KnowledgeBase EXAMPLE_NAME = "binding-models" logger = get_example_logger(EXAMPLE_NAME) diff --git a/examples/custom-settings/custom_settings.py b/examples/custom-settings/custom_settings.py index aebce48..67040f3 100644 --- a/examples/custom-settings/custom_settings.py +++ b/examples/custom-settings/custom_settings.py @@ -33,6 +33,9 @@ logger = get_example_logger(EXAMPLE_NAME) +# Subclass KnowledgeBaseSettings to add application-specific settings alongside the +# standard KB configuration fields. The model_config points to the YAML file for this +# example and enables CLI argument parsing. class AppSettings(KnowledgeBaseSettings): model_config = SettingsConfigDict( yaml_file="custom-settings/settings.yaml", @@ -45,6 +48,9 @@ class AppSettings(KnowledgeBaseSettings): db_port: int = 5432 debug: bool = False + # Override settings_customise_sources to prepend CliSettingsSource so that CLI + # arguments take the highest priority, followed by the sources defined in the base + # class (env vars, config file, defaults). @classmethod def settings_customise_sources(cls, settings_cls, **kwargs): # type: ignore return ( @@ -53,27 +59,38 @@ def settings_customise_sources(cls, settings_cls, **kwargs): # type: ignore ) +# Instantiate AppSettings to load configuration from all sources at once (CLI args, +# env vars, YAML file, and field defaults), in priority order. settings = AppSettings() # type: ignore -kb = KnowledgeBase.from_settings(settings) -kb.ki_from_settings_with_default_handler("ask-from-settings") -kb.ki_from_settings_with_default_handler("post-from-settings") -@kb.ki_from_settings("answer-from-settings") def example_answer_from_settings( binding_set: BindingSet, info: KnowledgeInteractionInfo ) -> BindingSet: return binding_set -@kb.ki_from_settings("react-from-settings") def example_react_from_settings( binding_set: BindingSet, info: KnowledgeInteractionInfo ) -> BindingSet: return binding_set +# Use KnowledgeBase.from_settings to build the KB from the settings object instead of +# passing explicit constructor arguments. KIs defined in the settings YAML are +# registered automatically; use .handler() to attach a handler function to each of +# the ANSWER/REACT KIs by name. This is required, otherwise .build() will fail. +kb = ( + KnowledgeBase.from_settings(settings) + .handler("answer-from-settings", example_answer_from_settings) + .handler("react-from-settings", example_react_from_settings) + .build() +) + + if __name__ == "__main__": + # After building, we can see that KI contexts are accessible, and so + # are the settings from configuration sources. ask_ctx = kb.ki_registry["ask-from-settings"] post_ctx = kb.ki_registry["post-from-settings"] diff --git a/examples/post_measurement.py b/examples/post_measurement.py index 38f98e4..cf048ee 100644 --- a/examples/post_measurement.py +++ b/examples/post_measurement.py @@ -5,12 +5,12 @@ from rdflib import URIRef from shared import get_example_logger +from src.kb.knowledge_base import KnowledgeBase from src.ke.models import ( BindingModel, Literal, Uri, ) -from src.knowledge_base import KnowledgeBase EXAMPLE_NAME = "post-measurement" logger = get_example_logger(EXAMPLE_NAME) diff --git a/src/__init__.py b/src/__init__.py index feaeee0..98df6c3 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,6 +1,7 @@ import logging -from .knowledge_base import KnowledgeBase +from .kb.builder import KnowledgeBaseBuilder +from .kb.knowledge_base import KnowledgeBase from .settings import KnowledgeBaseSettings __version__ = "0.1.0a0" diff --git a/src/kb/__init__.py b/src/kb/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/kb/builder.py b/src/kb/builder.py new file mode 100644 index 0000000..f00a4e0 --- /dev/null +++ b/src/kb/builder.py @@ -0,0 +1,102 @@ +from typing import Self + +from ..ke.models import KiTypes +from ..knowledge_interaction import Handler, KnowledgeInteractionContext +from ..settings import KnowledgeBaseSettings +from .knowledge_base import KnowledgeBase + + +class KnowledgeBaseBuilder: + """Builds a :class:`KnowledgeBase` from a :class:`~.settings.KnowledgeBaseSettings` + instance. + + Returned by :meth:`KnowledgeBase.from_settings`. Attach handlers for incoming + (ANSWER/REACT) knowledge interactions via :meth:`handler`, then call :meth:`build` + to obtain the configured :class:`KnowledgeBase`. ASK and POST KIs defined in + settings are registered automatically — no explicit call needed. + + Example:: + + builder = KnowledgeBase.from_settings(settings) + + @builder.handler("my-answer-ki") + def my_handler(binding_set, info): + return binding_set + + kb = builder.build() + kb.connect() + kb.register() + kb.start_handling_loop() + """ + + def __init__(self, settings: KnowledgeBaseSettings) -> None: + self._settings = settings + self._kb = KnowledgeBase( + id=settings.knowledge_base.id, + name=settings.knowledge_base.name, + description=settings.knowledge_base.description, + ke_url=settings.knowledge_engine_endpoint, + ) + self._unhandled_incoming: set[str] = { + ki.name + for ki in settings.knowledge_interactions + if ki.type in (KiTypes.ANSWER, KiTypes.REACT) + } + + def handler(self, ki_name: str, func: Handler) -> Self: + """Attach *func* as the handler for the ANSWER or REACT KI named *ki_name*. + + Args: + ki_name: Name of the KI as declared in settings. + func: Handler callable; receives a binding set and + :class:`~.ke.models.KnowledgeInteractionInfo` and returns a binding set. + + Raises: + ValueError: If *ki_name* is not declared in settings, or if the KI is of + type ASK or POST (outgoing KIs do not take handlers; they are registered + automatically). + """ + try: + info = self._settings.get_configured_interaction(ki_name) + except ValueError as err: + raise ValueError(f"KI named '{ki_name}' not found in settings.") from err + + if info.type not in (KiTypes.ANSWER, KiTypes.REACT): + raise ValueError( + f"KI '{ki_name}' is of type {info.type}. Only ANSWER and REACT KIs " + f"accept a handler; ASK and POST KIs are registered automatically." + ) + + self._kb._register_ki_decorator(info=info, defer_ke_registration=True)(func) + self._unhandled_incoming.discard(ki_name) + return self + + def build(self) -> KnowledgeBase: + """Return the configured :class:`KnowledgeBase`. + + All ASK and POST KIs from settings are registered at this point. ANSWER and + REACT KIs must have had their handlers attached via :meth:`handler` before + calling ``build()``. + + Raises: + ValueError: If any ANSWER or REACT KI from settings has no handler. + """ + if self._unhandled_incoming: + names = ", ".join(sorted(self._unhandled_incoming)) + raise ValueError( + f"The following ANSWER/REACT KIs from settings have no handler " + f"attached: {names}. Call builder.handler(ki_name, func) for each " + f"before building." + ) + + for ki in self._settings.knowledge_interactions: + if ki.type in (KiTypes.ASK, KiTypes.POST): + self._kb.register_ki( + KnowledgeInteractionContext( + info=ki, + handler=None, + ), + defer_ke_registration=True, + ) + + return self._kb diff --git a/src/knowledge_base.py b/src/kb/knowledge_base.py similarity index 82% rename from src/knowledge_base.py rename to src/kb/knowledge_base.py index f7d4ce5..692e0af 100644 --- a/src/knowledge_base.py +++ b/src/kb/knowledge_base.py @@ -1,13 +1,15 @@ +from __future__ import annotations + import logging from collections.abc import Callable, Sequence from enum import StrEnum from functools import wraps -from typing import Any +from typing import TYPE_CHECKING, Any -from .ke import Client -from .ke.client import ClientProtocol, PollResult -from .ke.errors import KnowledgeEngineNotAvailableError -from .ke.models import ( +from ..ke import Client +from ..ke.client import ClientProtocol, PollResult +from ..ke.errors import KnowledgeEngineNotAvailableError +from ..ke.models import ( AskAnswerInteractionInfo, BindingModel, BindingSet, @@ -16,12 +18,15 @@ KnowledgeInteractionInfo, PostReactInteractionInfo, ) -from .knowledge_interaction import ( +from ..knowledge_interaction import ( Handler, KnowledgeInteractionContext, KnowledgeInteractionStatus, ) -from .settings import KnowledgeBaseSettings + +if TYPE_CHECKING: + from ..settings import KnowledgeBaseSettings + from .builder import KnowledgeBaseBuilder logger = logging.getLogger(__name__) @@ -46,21 +51,20 @@ def __init__(self, id: str, name: str, description: str, ke_url: str): name=name, description=description, ) - self._build_settings: KnowledgeBaseSettings | None = None @classmethod - def from_settings(cls, settings: KnowledgeBaseSettings) -> "KnowledgeBase": - """Create a :class:`KnowledgeBase` from a + def from_settings(cls, settings: KnowledgeBaseSettings) -> KnowledgeBaseBuilder: + """Create a :class:`~.knowledge_base_builder.KnowledgeBaseBuilder` from a :class:`~.settings.KnowledgeBaseSettings` instance (or a subclass thereof). + + Attach handlers for incoming KIs via the builder's + :meth:`~.knowledge_base_builder.KnowledgeBaseBuilder.handler` method, then call + :meth:`~.knowledge_base_builder.KnowledgeBaseBuilder.build` to obtain the + configured :class:`KnowledgeBase`. """ - kb = cls( - id=settings.knowledge_base.id, - name=settings.knowledge_base.name, - description=settings.knowledge_base.description, - ke_url=settings.knowledge_engine_endpoint, - ) - kb._build_settings = settings - return kb + from .builder import KnowledgeBaseBuilder + + return KnowledgeBaseBuilder(settings) def connect(self) -> None: """Checks whether the KE runtime is available and raises an exception if not. @@ -375,91 +379,6 @@ def react_ki( defer_ke_registration=defer_ke_registration, ) - def ki_from_settings( - self, ki_name: str, defer_ke_registration: bool = True - ) -> Callable[[Handler], Handler]: - """Return a decorator that registers the decorated function as a KI - handler with info from the KB settings. - - Raises: - ValueError: If no settings are found or ``ki_name`` is not found in the - settings, or if registration constraints are violated. - SmartConnectorNotFoundError: Propagated from ``register_ki`` when contacting - the KE runtime. - UnexpectedHttpResponseError: Propagated from ``register_ki`` when contacting - the KE runtime. - """ - if not self._build_settings: - raise ValueError( - "Cannot register KI from settings because the KB was not built from " - "settings. Please build the KB using KnowledgeBase.from_settings() " - "with a KnowledgeBaseSettings that includes the desired KI info." - ) - try: - info = self._build_settings.get_configured_interaction(ki_name) - except ValueError as err: - raise ValueError( - f"KI named '{ki_name}' not found in KB settings. Please ensure the " - f"settings include a KI with this name." - ) from err - - return self._register_ki_decorator( - info=info, - defer_ke_registration=defer_ke_registration, - ) - - def ki_from_settings_with_default_handler( - self, ki_name: str, defer_ke_registration: bool = True - ) -> None: - """Register a KI that was defined in the settings of a KB. Only applicable to - KIs of type ASK or POST, which will be registered with the default ASK and POST - handlers, respectively. - - .. warning:: - The default ASK and POST handlers are not yet implemented. The KI will be - registered successfully, but invoking it will raise - :exc:`NotImplementedError`. Use :meth:`ki_from_settings` with a custom - handler instead. - - Raises: - ValueError: If no settings are found or ``ki_name`` is not found in the - settings, if the KI type is not ASK or POST, or if registration constraints - are violated. - SmartConnectorNotFoundError: Propagated from ``register_ki`` when contacting - the KE runtime. - UnexpectedHttpResponseError: Propagated from ``register_ki`` when contacting - the KE runtime. - """ - if not self._build_settings: - raise ValueError( - "Cannot register KI from settings because the KB was not built from " - "settings. Please build the KB using KnowledgeBase.from_settings() with" - " a KnowledgeBaseSettings that includes the desired KI info." - ) - - try: - info = self._build_settings.get_configured_interaction(ki_name) - except ValueError as err: - raise ValueError( - f"KI named '{ki_name}' not found in KB settings. Please ensure the " - f"settings include a KI with this name." - ) from err - - if not (info.type == KiTypes.ASK or info.type == KiTypes.POST): - raise ValueError( - f"KI named '{ki_name}' in settings must be of type ASK or POST to use " - f"the default handler registration method." - ) - - self.register_ki( - KnowledgeInteractionContext( - info=info, - handler=None, - ), - defer_ke_registration=defer_ke_registration, - ) - return - def call(self, binding_set: BindingSet, ki_name: str) -> BindingSet: """Invoke the handler of a registered KI by its name. diff --git a/src/legacy/__main__.py b/src/legacy/__main__.py index 18ac7ed..ce7bc4f 100644 --- a/src/legacy/__main__.py +++ b/src/legacy/__main__.py @@ -7,7 +7,7 @@ import time import signal import requests.exceptions -from src.knowledge_base import KnowledgeBaseUnregistered +from src.kb.knowledge_base import KnowledgeBaseUnregistered from src.knowledge_mapper import KnowledgeMapper from src.auth.sql_auth import SqlAuth diff --git a/src/legacy/data_source.py b/src/legacy/data_source.py index faa9583..90fab37 100644 --- a/src/legacy/data_source.py +++ b/src/legacy/data_source.py @@ -1,4 +1,4 @@ -from src.knowledge_base import KnowledgeBase +from src.kb.knowledge_base import KnowledgeBase class DataSource: def test(self): diff --git a/src/legacy/knowledge_mapper.py b/src/legacy/knowledge_mapper.py index cb079f6..e165ded 100644 --- a/src/legacy/knowledge_mapper.py +++ b/src/legacy/knowledge_mapper.py @@ -2,7 +2,7 @@ import logging as log from src.auth.base_auth import BaseAuth from src.utils import extract_variables -from src.knowledge_base import ( +from src.kb.knowledge_base import ( KnowledgeBaseRegistrationRequest, ) from src.knowledge_interaction import ( diff --git a/src/legacy/tests/legacy_test_tke_client.py b/src/legacy/tests/legacy_test_tke_client.py index 9159bf7..b4e6b0a 100644 --- a/src/legacy/tests/legacy_test_tke_client.py +++ b/src/legacy/tests/legacy_test_tke_client.py @@ -1,4 +1,4 @@ -import src.knowledge_base as tke_kb +import src.kb.knowledge_base as tke_kb import src.knowledge_interaction as tke_ki import src.tke_client as tke import pytest diff --git a/src/legacy/tke_client.py b/src/legacy/tke_client.py index 33f946b..811f9d2 100644 --- a/src/legacy/tke_client.py +++ b/src/legacy/tke_client.py @@ -3,7 +3,7 @@ import logging as log import time -import src.knowledge_base as knowledge_base +import src.kb.knowledge_base as knowledge_base from src.tke_exceptions import UnexpectedHttpResponseError diff --git a/src/legacy/wizard_mapper.py b/src/legacy/wizard_mapper.py index a5e501e..750395c 100644 --- a/src/legacy/wizard_mapper.py +++ b/src/legacy/wizard_mapper.py @@ -3,7 +3,7 @@ import os import requests -from src.knowledge_base import ( +from src.kb.knowledge_base import ( KnowledgeEngineTerminated, ) from src.utils import match_bindings diff --git a/tests/configuration/test_configuration.py b/tests/configuration/test_configuration.py index 1eb594f..dc5063c 100644 --- a/tests/configuration/test_configuration.py +++ b/tests/configuration/test_configuration.py @@ -10,7 +10,8 @@ class KbSettings(KnowledgeBaseSettings): ) settings = KbSettings() # pyright: ignore[reportCallIssue] - kb = KnowledgeBase.from_settings(settings) + builder = KnowledgeBase.from_settings(settings) + kb = builder.build() assert kb.info.id == settings.knowledge_base.id @@ -22,7 +23,8 @@ class KbSettings(KnowledgeBaseSettings): ) settings = KbSettings() # pyright: ignore[reportCallIssue] - kb = KnowledgeBase.from_settings(settings) + builder = KnowledgeBase.from_settings(settings) + kb = builder.build() assert kb.info.id == "http://example.org/test/config#kb-from-env" @@ -33,23 +35,23 @@ class KbSettings(KnowledgeBaseSettings): ) settings = KbSettings() # pyright: ignore[reportCallIssue] - kb = KnowledgeBase.from_settings(settings) - kb.ki_from_settings_with_default_handler("ask-from-settings") - kb.ki_from_settings_with_default_handler("post-from-settings") + builder = KnowledgeBase.from_settings(settings) - ask_ki = kb.ki_registry["ask-from-settings"] - assert ask_ki.info.name == "ask-from-settings" - post_ki = kb.ki_registry["post-from-settings"] - assert post_ki.info.name == "post-from-settings" - - @kb.ki_from_settings("answer-from-settings") def answer(binding_set, info): return binding_set - @kb.ki_from_settings("react-from-settings") def react(binding_set, info): return binding_set + builder.handler("answer-from-settings", answer) + builder.handler("react-from-settings", react) + + kb = builder.build() + + ask_ki = kb.ki_registry["ask-from-settings"] + assert ask_ki.info.name == "ask-from-settings" + post_ki = kb.ki_registry["post-from-settings"] + assert post_ki.info.name == "post-from-settings" answer_ki = kb.ki_registry["answer-from-settings"] assert answer_ki.info.name == "answer-from-settings" react_ki = kb.ki_registry["react-from-settings"] diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 4927bba..5af512d 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1,8 +1,8 @@ import pytest from rdflib import URIRef +from src.kb.knowledge_base import KnowledgeBase from src.ke.models import BindingModel, BindingSet, Uri -from src.knowledge_base import KnowledgeBase @pytest.fixture diff --git a/tests/test_kb_lifespan.py b/tests/test_kb_lifespan.py index 8fade58..ce565a5 100644 --- a/tests/test_kb_lifespan.py +++ b/tests/test_kb_lifespan.py @@ -3,9 +3,9 @@ import pytest from src import KnowledgeBase +from src.kb.knowledge_base import KnowledgeBaseState from src.ke.errors import KnowledgeEngineNotAvailableError from src.ke.testing import TestClient -from src.knowledge_base import KnowledgeBaseState @pytest.fixture diff --git a/tests/test_knowledge_base_builder.py b/tests/test_knowledge_base_builder.py new file mode 100644 index 0000000..0cf6ab8 --- /dev/null +++ b/tests/test_knowledge_base_builder.py @@ -0,0 +1,146 @@ +import pytest + +from src import KnowledgeBase, KnowledgeBaseSettings +from src.ke.models import ( + AskAnswerInteractionInfo, + BindingSet, + KiTypes, + KnowledgeBaseInfo, + KnowledgeInteractionInfo, + PostReactInteractionInfo, +) + + +def settings_factory( + ki_infos: list[KnowledgeInteractionInfo] | None = None, +) -> KnowledgeBaseSettings: + return KnowledgeBaseSettings( + knowledge_base=KnowledgeBaseInfo( + id="http://example.org/test#builder-kb", + name="builder-kb", + description="A KB for testing the builder.", + ), + knowledge_engine_endpoint="http://fake-ke", + knowledge_interactions=ki_infos or [], + ) + + +def answer_ki_info(name: str = "answer-ki") -> AskAnswerInteractionInfo: + return AskAnswerInteractionInfo( + name=name, type=KiTypes.ANSWER, graph_pattern="?s ?p ?o ." + ) + + +def ask_ki_info(name: str = "ask-ki") -> AskAnswerInteractionInfo: + return AskAnswerInteractionInfo( + name=name, type=KiTypes.ASK, graph_pattern="?s ?p ?o ." + ) + + +def post_ki_info(name: str = "post-ki") -> PostReactInteractionInfo: + return PostReactInteractionInfo( + name=name, + type=KiTypes.POST, + argument_graph_pattern="?s ?p ?o .", + result_graph_pattern="?s ?p ?o .", + ) + + +def react_ki_info(name: str = "react-ki") -> PostReactInteractionInfo: + return PostReactInteractionInfo( + name=name, + type=KiTypes.REACT, + argument_graph_pattern="?s ?p ?o .", + result_graph_pattern="?s ?p ?o .", + ) + + +def dummy_handler( + binding_set: BindingSet, info: KnowledgeInteractionInfo +) -> BindingSet: + return binding_set + + +# --- build() --- + + +def test_build_returns_knowledge_base_with_correct_info(): + settings = settings_factory() + builder = KnowledgeBase.from_settings(settings) + kb = builder.build() + assert isinstance(kb, KnowledgeBase) + assert kb.info.id == settings.knowledge_base.id + assert kb.info.name == settings.knowledge_base.name + + +def test_build_with_only_outgoing_kis_succeeds(): + """ASK and POST KIs need no handler; build() should succeed and register them.""" + settings = settings_factory([ask_ki_info(), post_ki_info()]) + builder = KnowledgeBase.from_settings(settings) + kb = builder.build() + assert "ask-ki" in kb.ki_registry + assert "post-ki" in kb.ki_registry + + +def test_build_raises_when_incoming_ki_has_no_handler(): + """build() must fail if an ANSWER or REACT KI from settings has no handler.""" + settings = settings_factory([answer_ki_info()]) + builder = KnowledgeBase.from_settings(settings) + with pytest.raises(ValueError, match="answer-ki"): + builder.build() + + +# --- handler() --- + + +def test_handler_attaches_to_answer_ki(): + settings = settings_factory([answer_ki_info()]) + builder = KnowledgeBase.from_settings(settings) + builder.handler("answer-ki", dummy_handler) + kb = builder.build() + assert "answer-ki" in kb.ki_registry + + +def test_handler_attaches_to_react_ki(): + settings = settings_factory([react_ki_info()]) + builder = KnowledgeBase.from_settings(settings) + builder.handler("react-ki", dummy_handler) + kb = builder.build() + assert "react-ki" in kb.ki_registry + + +def test_handler_raises_for_ki_not_in_settings(): + settings = settings_factory([answer_ki_info()]) + builder = KnowledgeBase.from_settings(settings) + with pytest.raises(ValueError, match="nonexistent"): + builder.handler("nonexistent", dummy_handler) + + +def test_handler_raises_for_ask_ki(): + """handler() is only for incoming (ANSWER/REACT) KIs.""" + settings = settings_factory([ask_ki_info()]) + builder = KnowledgeBase.from_settings(settings) + with pytest.raises(ValueError): + builder.handler("ask-ki", dummy_handler) + + +def test_handler_raises_for_post_ki(): + settings = settings_factory([post_ki_info()]) + builder = KnowledgeBase.from_settings(settings) + with pytest.raises(ValueError): + builder.handler("post-ki", dummy_handler) + + +def test_build_with_all_ki_types(): + """Full round-trip: all four KI types from settings.""" + settings = settings_factory( + [ask_ki_info(), answer_ki_info(), post_ki_info(), react_ki_info()] + ) + builder = KnowledgeBase.from_settings(settings) + builder.handler("answer-ki", dummy_handler) + builder.handler("react-ki", dummy_handler) + kb = builder.build() + assert "ask-ki" in kb.ki_registry + assert "answer-ki" in kb.ki_registry + assert "post-ki" in kb.ki_registry + assert "react-ki" in kb.ki_registry